Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
470 changes: 305 additions & 165 deletions src/components/admin/connections/ConnectionTestHistoryPanel.tsx

Large diffs are not rendered by default.

186 changes: 88 additions & 98 deletions src/components/admin/connections/CredentialsSourceIndicator.tsx

Large diffs are not rendered by default.

159 changes: 106 additions & 53 deletions src/components/admin/connections/FailedDeliveriesPanel.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<string | null>(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 };
Expand All @@ -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 (
<Card>
<CardHeader>
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<CardTitle className="flex items-center gap-2 text-base">
<AlertTriangle className="h-4 w-4 text-destructive" />
Entregas falhas
</CardTitle>
<CardDescription>
{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.
</CardDescription>
</div>
<div className="flex gap-2 items-center">
<div className="flex items-center gap-2">
<Input
value={eventFilter}
onChange={(e) => { 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"
/>
<ExportButton
filename="failed-deliveries"
rows={(data?.rows ?? []).map((d) => ({
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']}
/>
<Button size="sm" variant="ghost" onClick={() => refetch()} disabled={isFetching}>
<RefreshCw className={`h-3 w-3 ${isFetching ? "animate-spin" : ""}`} />
<RefreshCw className={`h-3 w-3 ${isFetching ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-2">{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-10 bg-muted/30 rounded animate-pulse" />
))}</div>
) : (data?.rows.length ?? 0) === 0 ? (
<p className="text-sm text-muted-foreground py-6 text-center">
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-10 animate-pulse rounded bg-muted/30" />
))}
</div>
) : rows.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
🎉 Nenhuma entrega falha. Tudo em ordem.
</p>
) : (
Expand All @@ -132,28 +152,41 @@ export function FailedDeliveriesPanel() {
</TableRow>
</TableHeader>
<TableBody>
{data!.rows.map((d) => (
{rows.map((d) => (
<TableRow key={d.id}>
<TableCell>
<div className="font-medium text-sm">{d.outbound_webhooks?.name ?? "—"}</div>
<div className="text-sm font-medium">{d.outbound_webhooks?.name ?? '—'}</div>
{d.outbound_webhooks?.active === false && (
<Badge variant="outline" className="text-[10px] mt-0.5 bg-destructive/10 text-destructive border-destructive/20">
<Badge
variant="outline"
className="mt-0.5 border-destructive/20 bg-destructive/10 text-[10px] text-destructive"
>
Webhook desativado
</Badge>
)}
</TableCell>
<TableCell><Badge variant="secondary" className="text-xs">{d.event}</Badge></TableCell>
<TableCell>
<span className="text-xs font-mono">{d.status_code ?? "—"}</span>
<Badge variant="secondary" className="text-xs">
{d.event}
</Badge>
</TableCell>
<TableCell>
<span className="font-mono text-xs">{d.status_code ?? '—'}</span>
{d.error_message && (
<div className="text-[10px] text-muted-foreground truncate max-w-[200px]" title={d.error_message}>
<div
className="max-w-[200px] truncate text-[10px] text-muted-foreground"
title={d.error_message}
>
{d.error_message}
</div>
)}
</TableCell>
<TableCell className="text-xs">{d.attempt}</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(d.delivered_at), { locale: ptBR, addSuffix: true })}
{formatDistanceToNow(new Date(d.delivered_at), {
locale: ptBR,
addSuffix: true,
})}
</TableCell>
<TableCell className="text-right">
<Button
Expand All @@ -163,7 +196,11 @@ export function FailedDeliveriesPanel() {
disabled={replayingId === d.id}
className="h-7 text-xs"
>
{replayingId === d.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCw className="h-3 w-3" />}
{replayingId === d.id ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<RotateCw className="h-3 w-3" />
)}
Reenviar
</Button>
</TableCell>
Expand All @@ -172,11 +209,27 @@ export function FailedDeliveriesPanel() {
</TableBody>
</Table>
{totalPages > 1 && (
<div className="flex items-center justify-between mt-3 text-xs text-muted-foreground">
<span>Página {page + 1} de {totalPages}</span>
<div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
<span>
Página {page + 1} de {totalPages}
</span>
<div className="flex gap-1">
<Button size="sm" variant="ghost" disabled={page === 0} onClick={() => setPage((p) => p - 1)}>Anterior</Button>
<Button size="sm" variant="ghost" disabled={page >= totalPages - 1} onClick={() => setPage((p) => p + 1)}>Próxima</Button>
<Button
size="sm"
variant="ghost"
disabled={page === 0}
onClick={() => setPage((p) => p - 1)}
>
Anterior
</Button>
<Button
size="sm"
variant="ghost"
disabled={page >= totalPages - 1}
onClick={() => setPage((p) => p + 1)}
>
Próxima
</Button>
</div>
</div>
)}
Expand Down
Loading
Loading