diff --git a/src/components/admin/connections/ConnectionTestHistoryPanel.tsx b/src/components/admin/connections/ConnectionTestHistoryPanel.tsx index 222e25014..b2c60787f 100644 --- a/src/components/admin/connections/ConnectionTestHistoryPanel.tsx +++ b/src/components/admin/connections/ConnectionTestHistoryPanel.tsx @@ -1,17 +1,31 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; -import { ChevronDown, ChevronRight, CheckCircle2, XCircle, Loader2, History, Bot, Info, AlertCircle } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { LatencyBadge } from "./LatencyBadge"; -import { ConnectionTimelineDrawer } from "./ConnectionTimelineDrawer"; -import { ConnectionTestDetailsDialog } from "./ConnectionTestDetailsDialog"; -import { useConnectionTestHistory, type ConnectionType, type TestHistoryItem } from "@/hooks/intelligence"; -import { getErrorCopy, getKindBadgeClass, getKindLabel } from "@/lib/connection-error-copy"; -import { inferErrorKind } from "@/lib/error-kind-inference"; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { + ChevronDown, + ChevronRight, + CheckCircle2, + XCircle, + Loader2, + History, + Bot, + Info, + AlertCircle, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { LatencyBadge } from './LatencyBadge'; +import { ConnectionTimelineDrawer } from './ConnectionTimelineDrawer'; +import { ConnectionTestDetailsDialog } from './ConnectionTestDetailsDialog'; +import { + useConnectionTestHistory, + type ConnectionType, + type TestHistoryItem, +} from '@/hooks/intelligence'; +import { getErrorCopy, getKindBadgeClass, getKindLabel } from '@/lib/connection-error-copy'; +import { inferErrorKind } from '@/lib/error-kind-inference'; interface Props { type: ConnectionType; - envKey?: "promobrind" | "crm"; + envKey?: 'promobrind' | 'crm'; connectionId?: string; /** Bump after a "Testar conexão" succeeds to refetch. */ refreshKey?: number | string; @@ -27,25 +41,27 @@ interface Props { } const PREVIEW_SIZE_OPTIONS = [5, 10, 20] as const; -type PreviewSize = typeof PREVIEW_SIZE_OPTIONS[number]; -const PREVIEW_SIZE_STORAGE_KEY = "connections.history_preview_size"; +type PreviewSize = (typeof PREVIEW_SIZE_OPTIONS)[number]; +const PREVIEW_SIZE_STORAGE_KEY = 'connections.history_preview_size'; const DEFAULT_PREVIEW_SIZE: PreviewSize = 5; function loadPreviewSize(): PreviewSize { - if (typeof window === "undefined") return DEFAULT_PREVIEW_SIZE; + if (typeof window === 'undefined') return DEFAULT_PREVIEW_SIZE; try { const raw = window.localStorage.getItem(PREVIEW_SIZE_STORAGE_KEY); - const parsed = parseInt(raw ?? "", 10); + const parsed = parseInt(raw ?? '', 10); if (PREVIEW_SIZE_OPTIONS.includes(parsed as PreviewSize)) return parsed as PreviewSize; - } catch { /* ignore */ } + } catch { + /* ignore */ + } return DEFAULT_PREVIEW_SIZE; } function formatRelative(iso: string): string { const ts = new Date(iso).getTime(); - if (Number.isNaN(ts)) return ""; + if (Number.isNaN(ts)) return ''; const diff = Date.now() - ts; - if (diff < 5_000) return "agora há pouco"; + if (diff < 5_000) return 'agora há pouco'; if (diff < 60_000) return `há ${Math.round(diff / 1000)}s`; if (diff < 3_600_000) return `há ${Math.round(diff / 60_000)}min`; if (diff < 86_400_000) return `há ${Math.round(diff / 3_600_000)}h`; @@ -54,24 +70,28 @@ function formatRelative(iso: string): string { function formatAbsolute(iso: string): string { try { - return new Date(iso).toLocaleString("pt-BR", { - day: "2-digit", month: "2-digit", year: "numeric", - hour: "2-digit", minute: "2-digit", second: "2-digit", + return new Date(iso).toLocaleString('pt-BR', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', }); } catch { return iso; } } -type StatusFilter = "all" | "ok" | "fail"; -type SourceFilter = "all" | "manual" | "cron"; +type StatusFilter = 'all' | 'ok' | 'fail'; +type SourceFilter = 'all' | 'manual' | 'cron'; function emptyMessage(status: StatusFilter, source: SourceFilter): string { - if (source === "cron" && status === "fail") return "Nenhuma falha do cron neste período 🎉"; - if (source === "cron") return "Nenhum teste automático neste período."; - if (source === "manual") return "Nenhum teste manual neste período."; - if (status === "fail") return "Nenhuma falha nos últimos testes 🎉"; - return "Nenhum teste com este filtro."; + if (source === 'cron' && status === 'fail') return 'Nenhuma falha do cron neste período 🎉'; + if (source === 'cron') return 'Nenhum teste automático neste período.'; + if (source === 'manual') return 'Nenhum teste manual neste período.'; + if (status === 'fail') return 'Nenhuma falha nos últimos testes 🎉'; + return 'Nenhum teste com este filtro.'; } interface SourceFilterChipsProps { @@ -84,36 +104,55 @@ interface SourceFilterChipsProps { cronTotal: number; } -function SourceFilterChips({ value, onChange, allCount, manualCount, cronOk, cronFail, cronTotal }: SourceFilterChipsProps) { +function SourceFilterChips({ + value, + onChange, + allCount, + manualCount, + cronOk, + cronFail, + cronTotal, +}: SourceFilterChipsProps) { const options: Array<{ key: SourceFilter; label: string; count: number }> = [ - { key: "all", label: "Todas as origens", count: allCount }, - { key: "manual", label: "Manuais", count: manualCount }, - { key: "cron", label: "Cron", count: cronTotal }, + { key: 'all', label: 'Todas as origens', count: allCount }, + { key: 'manual', label: 'Manuais', count: manualCount }, + { key: 'cron', label: 'Cron', count: cronTotal }, ]; return ( -
- Origem: +
+ + Origem: + {options.map((opt) => { const active = value === opt.key; return ( @@ -124,7 +163,15 @@ function SourceFilterChips({ value, onChange, allCount, manualCount, cronOk, cro } /** Mini sparkline SVG (sem libs) das latências (oldest → newest, esquerda → direita). */ -function LatencySparkline({ items, width = 64, height = 18 }: { items: TestHistoryItem[]; width?: number; height?: number }) { +function LatencySparkline({ + items, + width = 64, + height = 18, +}: { + items: TestHistoryItem[]; + width?: number; + height?: number; +}) { // Ordena cronologicamente asc (mais antigo à esquerda) e pega até 12 pontos const sorted = [...items] .filter((i) => i.latency_ms !== null) @@ -142,10 +189,12 @@ function LatencySparkline({ items, width = 64, height = 18 }: { items: TestHisto const points = sorted.map((it, idx) => { const x = idx * stepX; - const y = pad + innerH - ((it.latency_ms as number) - min) / range * innerH; + const y = pad + innerH - (((it.latency_ms as number) - min) / range) * innerH; return { x, y, ok: it.ok }; }); - const path = points.map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" "); + const path = points + .map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`) + .join(' '); const last = points[points.length - 1]; const allOk = points.every((p) => p.ok); @@ -165,7 +214,7 @@ function LatencySparkline({ items, width = 64, height = 18 }: { items: TestHisto fill="none" strokeWidth={1.25} className={cn( - allOk ? "stroke-green-500/80 dark:stroke-green-400/80" : "stroke-destructive/80", + allOk ? 'stroke-green-500/80 dark:stroke-green-400/80' : 'stroke-destructive/80', )} /> {points.map((p, i) => ( @@ -174,12 +223,16 @@ function LatencySparkline({ items, width = 64, height = 18 }: { items: TestHisto cx={p.x} cy={p.y} r={i === points.length - 1 ? 1.6 : 0.9} - className={cn( - p.ok ? "fill-green-600 dark:fill-green-400" : "fill-destructive", - )} + className={cn(p.ok ? 'fill-green-600 dark:fill-green-400' : 'fill-destructive')} /> ))} - + @@ -207,21 +260,19 @@ function PendingHistoryRow({ startedAt }: { startedAt: string }) {
  • - - iniciado {rel || "agora"} + + iniciado {rel || 'agora'} …ms - - Aguardando resposta do servidor… - - novo + Aguardando resposta do servidor… + novo
  • ); @@ -230,8 +281,8 @@ function PendingHistoryRow({ startedAt }: { startedAt: string }) { function HistoryRow({ item: it, onClick, highlighted, rowRef }: RowProps) { const Icon = it.ok ? CheckCircle2 : XCircle; const tail = it.ok - ? `HTTP ${it.status ?? "?"}${it.message ? ` — ${it.message}` : ""}` - : (it.message || "Falha"); + ? `HTTP ${it.status ?? '?'}${it.message ? ` — ${it.message}` : ''}` + : it.message || 'Falha'; // Para falhas, infere kind (com fallback heurístico para registros antigos) // e renderiza badge semântico ao lado da mensagem. const resolvedKind = !it.ok @@ -250,33 +301,40 @@ function HistoryRow({ item: it, onClick, highlighted, rowRef }: RowProps) { role="button" tabIndex={0} onClick={onClick} - onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onClick(); } }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick(); + } + }} className={cn( - "w-full grid grid-cols-[14px_minmax(80px,auto)_minmax(54px,auto)_1fr_auto] items-center gap-2 text-xs px-1.5 py-1 rounded transition-all text-left cursor-pointer scroll-mt-4", - "hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", - !it.ok && "bg-destructive/[0.03] hover:bg-destructive/10", - highlighted && "ring-2 ring-destructive/70 bg-destructive/15 animate-pulse-once", + 'grid w-full cursor-pointer scroll-mt-4 grid-cols-[14px_minmax(80px,auto)_minmax(54px,auto)_1fr_auto] items-center gap-2 rounded px-1.5 py-1 text-left text-xs transition-all', + 'hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', + !it.ok && 'bg-destructive/[0.03] hover:bg-destructive/10', + highlighted && 'animate-pulse-once bg-destructive/15 ring-2 ring-destructive/70', )} - aria-label={it.ok ? "Ver detalhes deste teste" : "Ver detalhes do erro"} + aria-label={it.ok ? 'Ver detalhes deste teste' : 'Ver detalhes do erro'} > - + - + {formatRelative(it.tested_at)} - {it.triggered_by === "cron" && ( + {it.triggered_by === 'cron' && ( )} {(it.attempts ?? 1) > 1 && ( @@ -287,24 +345,29 @@ function HistoryRow({ item: it, onClick, highlighted, rowRef }: RowProps) { {formatAbsolute(it.tested_at)} - {it.triggered_by === "cron" && " · automático (cron)"} - {it.triggered_by === "manual" && " · manual"} + {it.triggered_by === 'cron' && ' · automático (cron)'} + {it.triggered_by === 'manual' && ' · manual'} {(it.attempts ?? 1) > 1 && ( - <> · {it.attempts} tentativas{it.ok ? " (recuperou na 2ª)" : ""} + <> + {' '} + · {it.attempts} tentativas{it.ok ? ' (recuperou na 2ª)' : ''} + )} - + {kindCopy && ( {!it.ok ? ( Ver detalhes do erro ) : ( - + Ver → )} @@ -339,45 +402,62 @@ function HistoryRow({ item: it, onClick, highlighted, rowRef }: RowProps) { } export function ConnectionTestHistoryPanel({ - type, envKey, connectionId, refreshKey, label, className, defaultPreview = true, pendingTest = null, + type, + envKey, + connectionId, + refreshKey, + label, + className, + defaultPreview = true, + pendingTest = null, }: Props) { const [expanded, setExpanded] = useState(false); - const [filter, setFilter] = useState("all"); - const [source, setSource] = useState("all"); + const [filter, setFilter] = useState('all'); + const [source, setSource] = useState('all'); const [detailsId, setDetailsId] = useState(null); const [timelineOpen, setTimelineOpen] = useState(false); const [previewSize, setPreviewSize] = useState(() => loadPreviewSize()); const updatePreviewSize = (n: PreviewSize) => { setPreviewSize(n); - try { window.localStorage.setItem(PREVIEW_SIZE_STORAGE_KEY, String(n)); } catch { /* ignore */ } + try { + window.localStorage.setItem(PREVIEW_SIZE_STORAGE_KEY, String(n)); + } catch { + /* ignore */ + } }; // Limit fetched rows to cover the largest preview size + headroom for filtering const fetchLimit = expanded ? Math.max(20, previewSize * 2) : Math.max(10, previewSize + 5); const { items, total, loading } = useConnectionTestHistory({ - type, envKey, connectionId, refreshKey, + type, + envKey, + connectionId, + refreshKey, enabled: expanded, limit: fetchLimit, }); // Source-filtered base list — status counts and visible items both derive from here const sourceFiltered = useMemo(() => { - if (source === "manual") return items.filter((i) => i.triggered_by !== "cron"); - if (source === "cron") return items.filter((i) => i.triggered_by === "cron"); + if (source === 'manual') return items.filter((i) => i.triggered_by !== 'cron'); + if (source === 'cron') return items.filter((i) => i.triggered_by === 'cron'); return items; }, [items, source]); - const counts = useMemo(() => ({ - all: sourceFiltered.length, - ok: sourceFiltered.filter((i) => i.ok).length, - fail: sourceFiltered.filter((i) => !i.ok).length, - }), [sourceFiltered]); + const counts = useMemo( + () => ({ + all: sourceFiltered.length, + ok: sourceFiltered.filter((i) => i.ok).length, + fail: sourceFiltered.filter((i) => !i.ok).length, + }), + [sourceFiltered], + ); // Cron-only counts (always over the whole `items`) — used for the source chip badges const cronCounts = useMemo(() => { - const cron = items.filter((i) => i.triggered_by === "cron"); + const cron = items.filter((i) => i.triggered_by === 'cron'); return { total: cron.length, ok: cron.filter((i) => i.ok).length, @@ -387,20 +467,28 @@ export function ConnectionTestHistoryPanel({ const manualTotal = items.length - cronCounts.total; const visibleItems = useMemo(() => { - if (filter === "ok") return sourceFiltered.filter((i) => i.ok); - if (filter === "fail") return sourceFiltered.filter((i) => !i.ok); + if (filter === 'ok') return sourceFiltered.filter((i) => i.ok); + if (filter === 'fail') return sourceFiltered.filter((i) => !i.ok); return sourceFiltered; }, [sourceFiltered, filter]); - const previewItems = useMemo(() => visibleItems.slice(0, previewSize), [visibleItems, previewSize]); + const previewItems = useMemo( + () => visibleItems.slice(0, previewSize), + [visibleItems, previewSize], + ); const stats = useMemo(() => { if (items.length === 0) return null; - const latencies = items.filter((i) => i.ok && i.latency_ms !== null).map((i) => i.latency_ms!); + const latencies = items.flatMap((i) => (i.ok && i.latency_ms !== null ? [i.latency_ms] : [])); const avg = latencies.length ? Math.round(latencies.reduce((s, n) => s + n, 0) / latencies.length) : null; - return { rate: Math.round((counts.ok / items.length) * 100), avg, ok: counts.ok, total: items.length }; + return { + rate: Math.round((counts.ok / items.length) * 100), + avg, + ok: counts.ok, + total: items.length, + }; }, [items, counts.ok]); const empty = total === 0 && !loading; @@ -433,12 +521,12 @@ export function ConnectionTestHistoryPanel({ e?.stopPropagation(); if (!latestFailure) return; if (!expanded && !showPreview) setExpanded(true); - if (filter === "ok") setFilter("all"); + if (filter === 'ok') setFilter('all'); setHighlightId(latestFailure.id); // wait for next paint so the row exists in DOM requestAnimationFrame(() => { const el = rowRefs.current.get(latestFailure.id); - el?.scrollIntoView({ behavior: "smooth", block: "center" }); + el?.scrollIntoView({ behavior: 'smooth', block: 'center' }); el?.focus?.(); }); }; @@ -449,16 +537,18 @@ export function ConnectionTestHistoryPanel({ }; return ( -
    +
    )} {showPreview && ( -
    +
    {loading && previewItems.length === 0 && !pendingTest ? (
    - Carregando… + Carregando…
    ) : previewItems.length === 0 && !pendingTest ? (
    @@ -598,7 +704,13 @@ export function ConnectionTestHistoryPanel({
      {pendingTest && } {previewItems.map((it) => ( - setDetailsId(it.id)} highlighted={highlightId === it.id} rowRef={setRowRef(it.id)} /> + setDetailsId(it.id)} + highlighted={highlightId === it.id} + rowRef={setRowRef(it.id)} + /> ))}
    @@ -608,30 +720,32 @@ export function ConnectionTestHistoryPanel({ {/* Expandido: filtros + lista + stats + drawer */} {expanded && !empty && ( -
    +
    - {([ - { key: "all", label: "Todos", count: counts.all }, - { key: "ok", label: "OK", count: counts.ok }, - { key: "fail", label: "Falhas", count: counts.fail }, - ] as const).map((opt) => { + {( + [ + { key: 'all', label: 'Todos', count: counts.all }, + { key: 'ok', label: 'OK', count: counts.ok }, + { key: 'fail', label: 'Falhas', count: counts.fail }, + ] as const + ).map((opt) => { const active = filter === opt.key; - const isFail = opt.key === "fail"; - const isOk = opt.key === "ok"; + const isFail = opt.key === 'fail'; + const isOk = opt.key === 'ok'; return ( - Invalida o cache do secrets-manager{" "} - e recarrega integration_credentials{" "} - imediatamente. Útil após editar secrets em outra aba. + Invalida o cache do secrets-manager e recarrega{' '} + integration_credentials imediatamente. Útil após + editar secrets em outra aba. diff --git a/src/components/admin/connections/FailedDeliveriesPanel.tsx b/src/components/admin/connections/FailedDeliveriesPanel.tsx index 3375d1883..926421a33 100644 --- a/src/components/admin/connections/FailedDeliveriesPanel.tsx +++ b/src/components/admin/connections/FailedDeliveriesPanel.tsx @@ -1,16 +1,23 @@ -import { useState } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { supabase } from "@/integrations/supabase/client"; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { Input } from "@/components/ui/input"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { RefreshCw, RotateCw, AlertTriangle, Loader2 } from "lucide-react"; -import { toast } from "sonner"; -import { formatDistanceToNow } from "date-fns"; -import { ptBR } from "date-fns/locale"; -import { ExportButton } from "./ExportButton"; +import { useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { supabase } from '@/integrations/supabase/client'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { RefreshCw, RotateCw, AlertTriangle, Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { formatDistanceToNow } from 'date-fns'; +import { ptBR } from 'date-fns/locale'; +import { ExportButton } from './ExportButton'; interface FailedDelivery { id: string; @@ -28,19 +35,22 @@ const PAGE_SIZE = 25; export function FailedDeliveriesPanel() { const qc = useQueryClient(); const [page, setPage] = useState(0); - const [eventFilter, setEventFilter] = useState(""); + const [eventFilter, setEventFilter] = useState(''); const [replayingId, setReplayingId] = useState(null); const { data, isLoading, isFetching, refetch } = useQuery({ - queryKey: ["failed-deliveries", page, eventFilter], + queryKey: ['failed-deliveries', page, eventFilter], queryFn: async () => { let q = supabase - .from("webhook_deliveries") - .select("id, webhook_id, event, status_code, attempt, error_message, delivered_at, outbound_webhooks(name, url, active)", { count: "exact" }) - .eq("success", false) - .order("delivered_at", { ascending: false }) + .from('webhook_deliveries') + .select( + 'id, webhook_id, event, status_code, attempt, error_message, delivered_at, outbound_webhooks(name, url, active)', + { count: 'exact' }, + ) + .eq('success', false) + .order('delivered_at', { ascending: false }) .range(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE - 1); - if (eventFilter.trim()) q = q.ilike("event", `%${eventFilter.trim()}%`); + if (eventFilter.trim()) q = q.ilike('event', `%${eventFilter.trim()}%`); const { data, count, error } = await q; if (error) throw error; return { rows: (data ?? []) as unknown as FailedDelivery[], count: count ?? 0 }; @@ -51,71 +61,81 @@ export function FailedDeliveriesPanel() { const replay = async (id: string) => { setReplayingId(id); try { - const { data: result, error } = await supabase.functions.invoke("webhook-dispatcher", { - body: { event: "__replay__", replay_delivery_id: id }, + const { data: result, error } = await supabase.functions.invoke('webhook-dispatcher', { + body: { event: '__replay__', replay_delivery_id: id }, }); if (error) throw error; const r = result?.results?.[0]; - if (r?.status === "success") toast.success("Webhook reenviado com sucesso"); - else toast.warning("Reenviado, mas o destino respondeu com erro", { description: r?.attempts ? `${r.attempts} tentativas` : undefined }); - qc.invalidateQueries({ queryKey: ["failed-deliveries"] }); - qc.invalidateQueries({ queryKey: ["integrations-health"] }); + if (r?.status === 'success') toast.success('Webhook reenviado com sucesso'); + else + toast.warning('Reenviado, mas o destino respondeu com erro', { + description: r?.attempts ? `${r.attempts} tentativas` : undefined, + }); + qc.invalidateQueries({ queryKey: ['failed-deliveries'] }); + qc.invalidateQueries({ queryKey: ['integrations-health'] }); } catch (err) { - toast.error("Falha ao reenviar", { description: (err as Error).message }); + toast.error('Falha ao reenviar', { description: (err as Error).message }); } finally { setReplayingId(null); } }; const total = data?.count ?? 0; + const rows = data?.rows ?? []; const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); return ( -
    +
    Entregas falhas - {total} {total === 1 ? "entrega" : "entregas"} sem sucesso. Reenvie manualmente quando o destino estiver de volta. + {total} {total === 1 ? 'entrega' : 'entregas'} sem sucesso. Reenvie manualmente quando + o destino estiver de volta.
    -
    +
    { setEventFilter(e.target.value); setPage(0); }} + onChange={(e) => { + setEventFilter(e.target.value); + setPage(0); + }} placeholder="Filtrar por evento…" className="h-8 w-48 text-xs" /> ({ - webhook: d.outbound_webhooks?.name ?? "", - webhook_url: d.outbound_webhooks?.url ?? "", + webhook: d.outbound_webhooks?.name ?? '', + webhook_url: d.outbound_webhooks?.url ?? '', event: d.event, status_code: d.status_code, attempt: d.attempt, - error_message: d.error_message ?? "", + error_message: d.error_message ?? '', delivered_at: d.delivered_at, }))} - formats={["csv", "json"]} + formats={['csv', 'json']} />
    {isLoading ? ( -
    {Array.from({ length: 4 }).map((_, i) => ( -
    - ))}
    - ) : (data?.rows.length ?? 0) === 0 ? ( -

    +

    + {Array.from({ length: 4 }).map((_, i) => ( +
    + ))} +
    + ) : rows.length === 0 ? ( +

    🎉 Nenhuma entrega falha. Tudo em ordem.

    ) : ( @@ -132,28 +152,41 @@ export function FailedDeliveriesPanel() { - {data!.rows.map((d) => ( + {rows.map((d) => ( -
    {d.outbound_webhooks?.name ?? "—"}
    +
    {d.outbound_webhooks?.name ?? '—'}
    {d.outbound_webhooks?.active === false && ( - + Webhook desativado )}
    - {d.event} - {d.status_code ?? "—"} + + {d.event} + + + + {d.status_code ?? '—'} {d.error_message && ( -
    +
    {d.error_message}
    )} {d.attempt} - {formatDistanceToNow(new Date(d.delivered_at), { locale: ptBR, addSuffix: true })} + {formatDistanceToNow(new Date(d.delivered_at), { + locale: ptBR, + addSuffix: true, + })} @@ -172,11 +209,27 @@ export function FailedDeliveriesPanel() { {totalPages > 1 && ( -
    - Página {page + 1} de {totalPages} +
    + + Página {page + 1} de {totalPages} +
    - - + +
    )} diff --git a/src/components/admin/connections/SecretsManagerHealthPanel.tsx b/src/components/admin/connections/SecretsManagerHealthPanel.tsx index 9809ac09a..d51220960 100644 --- a/src/components/admin/connections/SecretsManagerHealthPanel.tsx +++ b/src/components/admin/connections/SecretsManagerHealthPanel.tsx @@ -17,11 +17,11 @@ * - Não inicia carga de secrets sozinho — apenas reage ao hook compartilhado. * - request_id é copiável para correlação com edge logs. */ -import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Activity, Clock, @@ -33,49 +33,49 @@ import { Zap, CheckCircle2, XCircle, -} from "lucide-react"; -import { supabase } from "@/integrations/supabase/client"; -import { newRequestId, REQUEST_ID_HEADER } from "@/lib/telemetry/requestId"; +} from 'lucide-react'; +import { supabase } from '@/integrations/supabase/client'; +import { newRequestId, REQUEST_ID_HEADER } from '@/lib/telemetry/requestId'; import { getSecretsManagerSamples, recordSecretsManagerCall, subscribeSecretsManagerCalls, type SecretsManagerCallSample, -} from "@/lib/telemetry/secretsManagerCallMetrics"; -import { useSecretsManager } from "@/hooks/admin"; -import { toast } from "sonner"; +} from '@/lib/telemetry/secretsManagerCallMetrics'; +import { useSecretsManager } from '@/hooks/admin'; +import { toast } from 'sonner'; const MAX_RECENT = 8; function describeListError(code: string, message: string): { title: string; hint: string } { switch (code) { - case "unauthenticated": + case 'unauthenticated': return { - title: "Sessão expirada — secrets-manager retornou 401", - hint: "Faça login novamente. O painel só consegue listar credenciais com sessão válida.", + title: 'Sessão expirada — secrets-manager retornou 401', + hint: 'Faça login novamente. O painel só consegue listar credenciais com sessão válida.', }; - case "forbidden": - case "permission_denied": + case 'forbidden': + case 'permission_denied': return { - title: "Sem permissão — secrets-manager retornou 403", - hint: "Apenas administradores conseguem ler credenciais. Verifique o papel do usuário em user_roles.", + title: 'Sem permissão — secrets-manager retornou 403', + hint: 'Apenas administradores conseguem ler credenciais. Verifique o papel do usuário em user_roles.', }; default: return { - title: "Falha ao ler do secrets-manager", - hint: message || "Erro inesperado. Veja os logs da edge function para detalhes.", + title: 'Falha ao ler do secrets-manager', + hint: message || 'Erro inesperado. Veja os logs da edge function para detalhes.', }; } } function formatTime(ts: number): string { - return new Date(ts).toLocaleTimeString("pt-BR", { hour12: false }); + return new Date(ts).toLocaleTimeString('pt-BR', { hour12: false }); } -function copyToClipboard(text: string, label = "Copiado") { +function copyToClipboard(text: string, label = 'Copiado') { navigator.clipboard.writeText(text).then( () => toast.success(label, { description: text }), - () => toast.error("Não foi possível copiar"), + () => toast.error('Não foi possível copiar'), ); } @@ -107,7 +107,7 @@ export function SecretsManagerHealthPanel({ className }: { className?: string }) const inferredBoot = useMemo(() => { for (let i = samples.length - 1; i >= 0; i--) { const s = samples[i]; - if (s.action === "list" || s.action === "status") return s; + if (s.action === 'list' || s.action === 'status') return s; } return null; }, [samples]); @@ -142,22 +142,23 @@ export function SecretsManagerHealthPanel({ className }: { className?: string }) try { // Action `status` é alias leve de `list`. Pedimos um nome inexistente // para minimizar payload — só queremos validar o roundtrip. - const { data, error } = await supabase.functions.invoke("secrets-manager", { - body: { action: "status", names: [] as string[] }, + const { data, error } = await supabase.functions.invoke('secrets-manager', { + body: { action: 'status', names: [] as string[] }, headers: { [REQUEST_ID_HEADER]: requestId }, }); const durationMs = Math.round(performance.now() - startedAt); const ctx = (error as { context?: Response } | null)?.context; const status = ctx?.status; const ok = !error && !!data && (data as { ok?: boolean }).ok !== false; - const errorMessage = error?.message - ?? (data && (data as { ok?: boolean }).ok === false + const errorMessage = + error?.message ?? + (data && (data as { ok?: boolean }).ok === false ? (data as { error?: { message?: string } }).error?.message : undefined); // Alimenta o mesmo buffer das chamadas reais para aparecer na lista. recordSecretsManagerCall({ - action: "status", + action: 'status', durationMs, ok, status, @@ -166,14 +167,23 @@ export function SecretsManagerHealthPanel({ className }: { className?: string }) }); setLastBoot({ ok, durationMs, status, error: errorMessage, requestId, ts: Date.now() }); - if (ok) toast.success("secrets-manager respondeu", { description: `${durationMs}ms` }); - else toast.error("secrets-manager falhou", { description: errorMessage ?? `HTTP ${status ?? "?"}` }); + if (ok) toast.success('secrets-manager respondeu', { description: `${durationMs}ms` }); + else + toast.error('secrets-manager falhou', { + description: errorMessage ?? `HTTP ${status ?? '?'}`, + }); } catch (err) { const durationMs = Math.round(performance.now() - startedAt); - const message = err instanceof Error ? err.message : "Erro desconhecido"; - recordSecretsManagerCall({ action: "status", durationMs, ok: false, errorMessage: message, requestId }); + const message = err instanceof Error ? err.message : 'Erro desconhecido'; + recordSecretsManagerCall({ + action: 'status', + durationMs, + ok: false, + errorMessage: message, + requestId, + }); setLastBoot({ ok: false, durationMs, error: message, requestId, ts: Date.now() }); - toast.error("Falha de rede ao chamar secrets-manager", { description: message }); + toast.error('Falha de rede ao chamar secrets-manager', { description: message }); } finally { setPinging(false); } @@ -185,7 +195,9 @@ export function SecretsManagerHealthPanel({ className }: { className?: string }) useEffect(() => { if (!boot && !pinging) { // Pequeno delay para não competir com o list() inicial da página. - const t = window.setTimeout(() => { ping(); }, 1200); + const t = window.setTimeout(() => { + ping(); + }, 1200); return () => window.clearTimeout(t); } }, [boot, pinging, ping]); @@ -195,17 +207,29 @@ export function SecretsManagerHealthPanel({ className }: { className?: string }) const totalCount = samples.length; const bootBadge = !boot - ? { label: "Sem heartbeat", cls: "border-muted-foreground/40 bg-muted/40 text-muted-foreground", Icon: Clock } + ? { + label: 'Sem heartbeat', + cls: 'border-muted-foreground/40 bg-muted/40 text-muted-foreground', + Icon: Clock, + } : boot.ok - ? { label: "Operacional", cls: "border-success/40 bg-success/10 text-success", Icon: CheckCircle2 } - : { label: "Falhou", cls: "border-destructive/40 bg-destructive/10 text-destructive", Icon: XCircle }; + ? { + label: 'Operacional', + cls: 'border-success/40 bg-success/10 text-success', + Icon: CheckCircle2, + } + : { + label: 'Falhou', + cls: 'border-destructive/40 bg-destructive/10 text-destructive', + Icon: XCircle, + }; return ( -
    +
    -
    +
    @@ -217,9 +241,13 @@ export function SecretsManagerHealthPanel({ className }: { className?: string })
    @@ -227,12 +255,9 @@ export function SecretsManagerHealthPanel({ className }: { className?: string }) {/* Linha de status do boot */} -
    - - +
    + + {bootBadge.label}
    @@ -240,7 +265,7 @@ export function SecretsManagerHealthPanel({ className }: { className?: string }) {boot ? ( {boot.durationMs}ms - {typeof boot.status === "number" && <> · HTTP {boot.status}} + {typeof boot.status === 'number' && <> · HTTP {boot.status}} <> · às {formatTime(boot.ts)} ) : ( @@ -250,8 +275,8 @@ export function SecretsManagerHealthPanel({ className }: { className?: string }) {boot?.requestId && (
    {/* Erro de leitura corrente (do hook compartilhado) */} - {listError && (() => { - const { title, hint } = describeListError(listError.code, listError.message); - return ( - - {listError.code === "unauthenticated" || listError.code === "forbidden" || listError.code === "permission_denied" ? ( - - ) : ( - - )} - {title} - -

    {hint}

    -

    - código: {listError.code} -

    -
    -
    - ); - })()} + {listError && + (() => { + const { title, hint } = describeListError(listError.code, listError.message); + return ( + + {listError.code === 'unauthenticated' || + listError.code === 'forbidden' || + listError.code === 'permission_denied' ? ( + + ) : ( + + )} + {title} + +

    {hint}

    +

    + código: {listError.code} +

    +
    +
    + ); + })()} {/* Últimas chamadas */}
    -
    +

    - Últimas chamadas{" "} + Últimas chamadas{' '} ({recent.length}/{totalCount} - {errorCount > 0 && · {errorCount} erro{errorCount === 1 ? "" : "s"}}) + {errorCount > 0 && ( + + {' '} + · {errorCount} erro{errorCount === 1 ? '' : 's'} + + )} + )

    {recent.length === 0 ? ( -

    +

    Nenhuma chamada registrada ainda nesta sessão.

    ) : ( @@ -310,53 +344,54 @@ export function SecretsManagerHealthPanel({ className }: { className?: string }) } function SampleRow({ sample }: { sample: SecretsManagerCallSample }) { + const requestId = sample.requestId; const tone = sample.ok - ? "border-success/30 bg-success/5" - : "border-destructive/40 bg-destructive/5"; + ? 'border-success/30 bg-success/5' + : 'border-destructive/40 bg-destructive/5'; return (
  • - {sample.ok ? "OK" : "ERR"} + {sample.ok ? 'OK' : 'ERR'} {sample.action} {sample.target && ( - + {sample.target} )} {sample.durationMs}ms - {typeof sample.status === "number" && ( + {typeof sample.status === 'number' && ( HTTP {sample.status} )} {formatTime(sample.ts)} {!sample.ok && sample.errorMessage && ( - + · {sample.errorMessage} )} - {sample.requestId && ( + {requestId && ( )}
  • diff --git a/src/components/admin/connections/SupabaseConnectionsTab.tsx b/src/components/admin/connections/SupabaseConnectionsTab.tsx index d3e004ffe..2e233bd23 100644 --- a/src/components/admin/connections/SupabaseConnectionsTab.tsx +++ b/src/components/admin/connections/SupabaseConnectionsTab.tsx @@ -54,6 +54,13 @@ const ENVS = [ }, ] as const; +type SupabaseEnv = (typeof ENVS)[number]; +type ManagedSupabaseEnv = Extract; + +function isManagedSupabaseEnv(env: SupabaseEnv): env is ManagedSupabaseEnv { + return !env.readOnly; +} + export function SupabaseConnectionsTab() { const { secrets, list, listError } = useSecretsManager(); const { test, isTesting, fetchLastTest } = useConnectionTester(); @@ -71,8 +78,8 @@ export function SupabaseConnectionsTab() { const hydrate = useCallback(async () => { const entries = await Promise.all( - ENVS.filter((e) => e.envKey).map(async (e) => { - const last = await fetchLastTest('supabase', { env_key: e.envKey! }); + ENVS.filter(isManagedSupabaseEnv).map(async (e) => { + const last = await fetchLastTest('supabase', { env_key: e.envKey }); return [ e.key, last @@ -118,28 +125,30 @@ export function SupabaseConnectionsTab() { return (
    {ENVS.map((env) => { - const url = env.urlSecret ? get(env.urlSecret) : undefined; - const anon = env.anonSecret ? get(env.anonSecret) : undefined; - const svc = env.serviceSecret ? get(env.serviceSecret) : undefined; - const last = env.readOnly ? null : (lastByEnv[env.key] ?? null); + const isManaged = isManagedSupabaseEnv(env); + const url = isManaged ? get(env.urlSecret) : undefined; + const anon = isManaged ? get(env.anonSecret) : undefined; + const svc = isManaged ? get(env.serviceSecret) : undefined; + const last = isManaged ? (lastByEnv[env.key] ?? null) : null; + const pendingStartedAt = pendingByEnv[env.key]; const credsConfigured = !!url?.has_value && !!svc?.has_value; - const suspicious = !env.readOnly - ? hasSuspiciousLength(secrets, [env.urlSecret!, env.anonSecret!, env.serviceSecret!]) + const suspicious = isManaged + ? hasSuspiciousLength(secrets, [env.urlSecret, env.anonSecret, env.serviceSecret]) : false; const credsLooksValid = credsConfigured && !suspicious; - const preflightIssues = !env.readOnly + const preflightIssues = isManaged ? getPreflightIssues(secrets, [ - { name: env.urlSecret!, label: 'URL do projeto' }, - { name: env.serviceSecret!, label: 'Service Role Key' }, + { name: env.urlSecret, label: 'URL do projeto' }, + { name: env.serviceSecret, label: 'Service Role Key' }, ]) : []; const status = resolveSupabaseConnectionStatus({ - readOnly: !!env.readOnly, + readOnly: !isManaged, url, service: svc, last, }); - const canTest = !env.readOnly && credsLooksValid && preflightIssues.length === 0; + const canTest = isManaged && credsLooksValid && preflightIssues.length === 0; return ( {env.description} - {env.readOnly ? ( + {!isManaged ? (

    Credenciais gerenciadas automaticamente. Não requer configuração manual.

    @@ -174,21 +183,21 @@ export function SupabaseConnectionsTab() { /> handleTest(env.envKey!, env.key)} + onClick={() => handleTest(env.envKey, env.key)} > {isTesting ? 'Testando…' : 'Testar conexão'} @@ -258,7 +267,7 @@ export function SupabaseConnectionsTab() { } action={ handleTest(env.envKey!, env.key)} + onRetest={() => handleTest(env.envKey, env.key)} disabled={!canTest} cooldownKey={`supabase:${env.envKey}`} disabledReason={ @@ -273,19 +282,17 @@ export function SupabaseConnectionsTab() { /> setDetailsDialogByEnv((cur) => ({ ...cur, [env.key]: v }))} connectionType="supabase" connectionLabel={env.name} - envKey={env.envKey!} + envKey={env.envKey} onViewFullHistory={() => setTimelineOpenByEnv((cur) => ({ ...cur, [env.key]: true })) } @@ -298,11 +305,11 @@ export function SupabaseConnectionsTab() { status={status} last={last} fields={[ - { label: 'URL do projeto', secretName: env.urlSecret!, status: url }, - { label: 'Anon Key', secretName: env.anonSecret!, status: anon }, + { label: 'URL do projeto', secretName: env.urlSecret, status: url }, + { label: 'Anon Key', secretName: env.anonSecret, status: anon }, { label: 'Service Role Key', - secretName: env.serviceSecret!, + secretName: env.serviceSecret, status: svc, sensitive: true, }, diff --git a/src/components/admin/connections/__tests__/ConnectionUI.test.tsx b/src/components/admin/connections/__tests__/ConnectionUI.test.tsx index d761bd3a2..6db866893 100644 --- a/src/components/admin/connections/__tests__/ConnectionUI.test.tsx +++ b/src/components/admin/connections/__tests__/ConnectionUI.test.tsx @@ -3,8 +3,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ConnectionsOverviewTable } from '../ConnectionsOverviewTable'; import { TooltipProvider } from '@/components/ui/tooltip'; import { useAuth } from '@/contexts/AuthContext'; -import { useConnectionsOverview } from '@/hooks/intelligence'; -import { useConnectionTester } from '@/hooks/intelligence'; +import { useConnectionTester, useConnectionsOverview } from '@/hooks/intelligence'; // Mocks vi.mock('@/contexts/AuthContext', () => ({ @@ -43,6 +42,10 @@ vi.mock('@/hooks/intelligence', () => ({ })); describe('ConnectionsOverviewTable Interações e Acessibilidade', () => { + const useAuthMock = vi.mocked(useAuth); + const useConnectionsOverviewMock = vi.mocked(useConnectionsOverview); + const useConnectionTesterMock = vi.mocked(useConnectionTester); + const mockRows = [ { id: '1', @@ -57,13 +60,13 @@ describe('ConnectionsOverviewTable Interações e Acessibilidade', () => { beforeEach(() => { vi.clearAllMocks(); - (useAuth as any).mockReturnValue({ isAdmin: true }); - (useConnectionsOverview as any).mockReturnValue({ + useAuthMock.mockReturnValue({ isAdmin: true }); + useConnectionsOverviewMock.mockReturnValue({ rows: mockRows, loading: false, refresh: vi.fn(), }); - (useConnectionTester as any).mockReturnValue({ test: vi.fn(), testing: false }); + useConnectionTesterMock.mockReturnValue({ test: vi.fn(), testing: false }); }); it('deve permitir focar e navegar nos botões de ação via teclado', () => { diff --git a/src/components/admin/connections/__tests__/ConnectionsOverviewTable.test.tsx b/src/components/admin/connections/__tests__/ConnectionsOverviewTable.test.tsx index 171fc2fd8..4b7e16e65 100644 --- a/src/components/admin/connections/__tests__/ConnectionsOverviewTable.test.tsx +++ b/src/components/admin/connections/__tests__/ConnectionsOverviewTable.test.tsx @@ -2,8 +2,7 @@ import { render, screen, fireEvent } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ConnectionsOverviewTable } from '../ConnectionsOverviewTable'; import { useAuth } from '@/contexts/AuthContext'; -import { useConnectionsOverview } from '@/hooks/intelligence'; -import { useConnectionTester } from '@/hooks/intelligence'; +import { useConnectionTester, useConnectionsOverview } from '@/hooks/intelligence'; import { useConsecutiveFailures } from '@/hooks/common'; import { useSecretsManager } from '@/hooks/admin'; import { TooltipProvider } from '@/components/ui/tooltip'; @@ -38,6 +37,12 @@ vi.mock('@/hooks/intelligence', () => ({ })); describe('ConnectionsOverviewTable Regression Tests', () => { + const useAuthMock = vi.mocked(useAuth); + const useConnectionsOverviewMock = vi.mocked(useConnectionsOverview); + const useConnectionTesterMock = vi.mocked(useConnectionTester); + const useConsecutiveFailuresMock = vi.mocked(useConsecutiveFailures); + const useSecretsManagerMock = vi.mocked(useSecretsManager); + const mockRows = [ { key: 'conn-1', @@ -69,23 +74,23 @@ describe('ConnectionsOverviewTable Regression Tests', () => { beforeEach(() => { vi.clearAllMocks(); - (useAuth as any).mockReturnValue({ isAdmin: true }); - (useConnectionsOverview as any).mockReturnValue({ + useAuthMock.mockReturnValue({ isAdmin: true }); + useConnectionsOverviewMock.mockReturnValue({ rows: mockRows, loading: false, refreshing: false, refresh: vi.fn(), patchRow: vi.fn(), }); - (useConnectionTester as any).mockReturnValue({ + useConnectionTesterMock.mockReturnValue({ test: vi.fn(), testing: false, }); - (useConsecutiveFailures as any).mockReturnValue({ + useConsecutiveFailuresMock.mockReturnValue({ map: new Map(), loading: false, }); - (useSecretsManager as any).mockReturnValue({ + useSecretsManagerMock.mockReturnValue({ secrets: [], list: vi.fn(), }); @@ -118,7 +123,7 @@ describe('ConnectionsOverviewTable Regression Tests', () => { it('should trigger refresh when button is clicked', async () => { const refreshMock = vi.fn(); - (useConnectionsOverview as any).mockReturnValue({ + useConnectionsOverviewMock.mockReturnValue({ rows: mockRows, loading: false, refreshing: false, @@ -139,7 +144,7 @@ describe('ConnectionsOverviewTable Regression Tests', () => { }); it('should handle empty state', async () => { - (useConnectionsOverview as any).mockReturnValue({ + useConnectionsOverviewMock.mockReturnValue({ rows: [], loading: false, refreshing: false, diff --git a/src/components/admin/connections/useSecretField.ts b/src/components/admin/connections/useSecretField.ts index f3d4feb35..ef35f5e11 100644 --- a/src/components/admin/connections/useSecretField.ts +++ b/src/components/admin/connections/useSecretField.ts @@ -1,10 +1,6 @@ import { useState, useRef, useEffect, useMemo } from 'react'; import { toast } from 'sonner'; -import { - useSecretsManager, - type SecretStatus, - type SecretMutationResult, -} from '@/hooks/admin'; +import { useSecretsManager, type SecretStatus, type SecretMutationResult } from '@/hooks/admin'; import { normalizeSecret } from './secretNormalizers'; import { validateSecretName } from './secretWhitelist'; import { validateSecret, getMinLength, MIN_SUFFIX_LENGTH } from './secretValidators'; @@ -143,7 +139,7 @@ export function useSecretField({ secretName, status, connectionId, onSaved }: Us } catch { /* empty */ } - }, [secretName, connectionId]); + }, [secretName, connectionId, draftScope, draftKey, legacyDraftKey]); useEffect(() => { if (editing && value.length > 0) {