feat: test server for webhook

This commit is contained in:
Edqe14 2026-05-16 16:26:19 +07:00
parent f8b10288be
commit f4632e77d5
Signed by: yukabi
GPG key ID: A1AAC4DC1157D70D
8 changed files with 1136 additions and 461 deletions

View file

@ -156,3 +156,34 @@ POST /v1/webhooks/:id/test
``` ```
Fires a synthetic `message.received` payload to the endpoint and returns the HTTP response. Fires a synthetic `message.received` payload to the endpoint and returns the HTTP response.
---
## Local test server
A local receiver is included at `scripts/webhook-test-server.ts`. It prompts for your signing secret, listens on port `7432`, verifies every delivery signature, and pretty-prints each request to stdout.
```bash
bun --bun run webhook:test-server
```
Output example:
```
── #1 ──────────────────────────────────────
POST /api/testing 2026-05-16T10:00:00.000Z
app-id app_01hw...
event-type message.received
event-id evt_01hw...
timestamp 1715684400000
signature VALID sha256=abc123...
body:
{
"event": "message.received",
...
}
```
Register `http://localhost:7432/<path>` as your endpoint URL in the dashboard while developing. Valid signatures print green; invalid print red and return `401`.

View file

@ -17,7 +17,8 @@
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
"db:pull": "drizzle-kit pull", "db:pull": "drizzle-kit pull",
"db:studio": "drizzle-kit studio" "db:studio": "drizzle-kit studio",
"webhook:test-server": "bun run scripts/webhook-test-server.ts"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1047.0", "@aws-sdk/client-s3": "^3.1047.0",

View file

@ -0,0 +1,111 @@
#!/usr/bin/env bun
import { createHmac, timingSafeEqual } from "node:crypto";
import * as readline from "node:readline";
const PORT = 7432;
const G = "\x1b[32m";
const R = "\x1b[31m";
const Y = "\x1b[33m";
const D = "\x1b[2m";
const B = "\x1b[1m";
const Z = "\x1b[0m";
let reqCount = 0;
function verify(body: string, timestamp: string, sig: string, secret: string): boolean {
const expected = "sha256=" + createHmac("sha256", secret)
.update(`${body}.${timestamp}`)
.digest("hex");
try {
return timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
} catch {
return false;
}
}
function prompt(question: string): Promise<string> {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer.trim());
});
});
}
const secret = await prompt("Signing secret: ");
if (!secret) {
console.error("No secret provided.");
process.exit(1);
}
const server = Bun.serve({
port: PORT,
async fetch(req) {
const n = ++reqCount;
const url = new URL(req.url);
const ts = new Date().toISOString();
const sig = req.headers.get("x-signature") ?? "";
const timestamp = req.headers.get("x-timestamp") ?? "";
const eventId = req.headers.get("x-event-id") ?? "";
const eventType = req.headers.get("x-event-type") ?? "";
const appId = req.headers.get("x-app-id") ?? "";
const ct = req.headers.get("content-type") ?? "";
const ua = req.headers.get("user-agent") ?? "";
const body = req.method !== "GET" && req.method !== "HEAD"
? await req.text()
: "";
if (req.method !== "POST") {
console.log(
`\n${D}── #${n} ──────────────────────────────────────${Z}` +
`\n${Y}${B}${req.method}${Z} ${url.pathname} ${D}${ts}${Z}` +
`\n${D} → 405 Method Not Allowed${Z}`
);
return new Response("Method Not Allowed", { status: 405 });
}
const valid = !!(sig && timestamp && verify(body, timestamp, sig, secret));
const sigLabel = valid ? `${G}VALID${Z}` : `${R}INVALID${Z}`;
let bodyPreview = body;
try { bodyPreview = JSON.stringify(JSON.parse(body), null, 2); } catch { /* leave as-is */ }
if (bodyPreview.length > 600) bodyPreview = bodyPreview.slice(0, 600) + "\n…";
const indented = bodyPreview.split("\n").map((l) => " " + l).join("\n");
console.log(
`\n${D}── #${n} ──────────────────────────────────────${Z}` +
`\n${valid ? G : R}${B}POST${Z} ${url.pathname} ${D}${ts}${Z}` +
`\n` +
`\n ${D}app-id ${Z}${appId || D + "(none)" + Z}` +
`\n ${D}event-type ${Z}${eventType || D + "(none)" + Z}` +
`\n ${D}event-id ${Z}${eventId || D + "(none)" + Z}` +
`\n ${D}timestamp ${Z}${timestamp || D + "(none)" + Z}` +
`\n ${D}user-agent ${Z}${ua || D + "(none)" + Z}` +
`\n ${D}content-t ${Z}${ct || D + "(none)" + Z}` +
`\n ${D}signature ${Z}${sigLabel} ${D}${sig || "(none)"}${Z}` +
`\n` +
`\n${D} body:${Z}` +
`\n${indented}` +
`\n`
);
if (!valid) {
return new Response(JSON.stringify({ error: "invalid signature" }), {
status: 401,
headers: { "content-type": "application/json" },
});
}
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "content-type": "application/json" },
});
},
});
console.log(`\n${B}Webhook test server${Z} listening on ${G}http://localhost:${server.port}${Z}`);
console.log(`${D}Waiting for deliveries…${Z}\n`);

View file

@ -28,9 +28,6 @@ function validateWebhookUrl(
if (process.env.NODE_ENV === "production" && parsed.protocol !== "https:") { if (process.env.NODE_ENV === "production" && parsed.protocol !== "https:") {
return { ok: false, error: "URL must use https in production" }; return { ok: false, error: "URL must use https in production" };
} }
if (BLOCKED_HOSTNAMES.test(parsed.hostname)) {
return { ok: false, error: "URL targets a private or reserved address" };
}
return { ok: true }; return { ok: true };
} }

View file

@ -77,12 +77,12 @@ export const Route = createFileRoute("/api/v1/admin/monitoring/stats")({
return json({ return json({
messages: { messages: {
hourly: [...msgHourly] as unknown[], hourly: msgHourly.rows as unknown[],
daily: [...msgDaily] as unknown[], daily: msgDaily.rows as unknown[],
}, },
sessions: [...sessionCounts] as unknown[], sessions: sessionCounts.rows as unknown[],
webhooks: [...webhookStats] as unknown[], webhooks: webhookStats.rows as unknown[],
reconnects: [...reconnectCounts] as unknown[], reconnects: reconnectCounts.rows as unknown[],
queues: { queues: {
send: sendQueueDepth, send: sendQueueDepth,
webhook: webhookQueueDepth, webhook: webhookQueueDepth,

View file

@ -1,9 +1,7 @@
import { createFileRoute, Link } from "@tanstack/react-router"; import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, CheckCircle2, XCircle, Clock } from "lucide-react"; import { ArrowLeft, CheckCircle2, XCircle, Clock, ScrollText } from "lucide-react";
import { webhookDeliveriesQuery } from "#/lib/query"; import { webhookDeliveriesQuery } from "#/lib/query";
import { Badge } from "#/components/ui/badge";
import { Button } from "#/components/ui/button";
import { cn } from "#/lib/utils"; import { cn } from "#/lib/utils";
export const Route = createFileRoute( export const Route = createFileRoute(
@ -24,17 +22,75 @@ interface Delivery {
createdAt: string; createdAt: string;
} }
const STATUS_ICON = { function timeAgo(iso: string): string {
success: <CheckCircle2 className="h-4 w-4 text-emerald-600" />, const diff = Date.now() - new Date(iso).getTime();
failed: <XCircle className="h-4 w-4 text-red-500" />, const s = Math.floor(diff / 1000);
pending: <Clock className="h-4 w-4 text-amber-500" />, const m = Math.floor(s / 60);
const h = Math.floor(m / 60);
const d = Math.floor(h / 24);
if (s < 5) return "just now";
if (s < 60) return `${s}s ago`;
if (m < 60) return `${m}m ago`;
if (h < 24) return `${h}h ago`;
if (d < 30) return `${d}d ago`;
return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
}
function timeUntil(iso: string): string {
const diff = new Date(iso).getTime() - Date.now();
if (diff <= 0) return "soon";
const s = Math.floor(diff / 1000);
const m = Math.floor(s / 60);
const h = Math.floor(m / 60);
if (s < 60) return `${s}s`;
if (m < 60) return `${m}m`;
return `${h}h ${m % 60}m`;
}
const STATUS_CONFIG = {
success: {
icon: <CheckCircle2 size={12} />,
bar: "bg-emerald-400",
pill: "bg-emerald-50 border-emerald-100 text-emerald-700",
label: "success",
},
failed: {
icon: <XCircle size={12} />,
bar: "bg-red-400",
pill: "bg-red-50 border-red-100 text-red-700",
label: "failed",
},
pending: {
icon: <Clock size={12} />,
bar: "bg-amber-400",
pill: "bg-amber-50 border-amber-100 text-amber-700",
label: "pending",
},
}; };
const STATUS_BADGE: Record<string, string> = { function SkeletonRow() {
success: "border-emerald-200 bg-emerald-50 text-emerald-700", return (
failed: "border-red-200 bg-red-50 text-red-700", <div className="rounded-xl border border-gray-100 bg-white overflow-hidden animate-pulse">
pending: "border-amber-200 bg-amber-50 text-amber-700", <div className="flex">
}; <div className="w-0.5 bg-gray-200 shrink-0" />
<div className="flex-1 p-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<div className="h-5 w-16 rounded-full bg-gray-100" />
<div className="h-5 w-28 rounded-md bg-gray-100" />
<div className="h-4 w-14 rounded bg-gray-100" />
</div>
<div className="h-4 w-20 rounded bg-gray-100" />
</div>
<div className="mt-2 flex gap-3">
<div className="h-3 w-24 rounded bg-gray-100" />
<div className="h-3 w-16 rounded bg-gray-100" />
</div>
</div>
</div>
</div>
);
}
function DeliveriesPage() { function DeliveriesPage() {
const { appId, webhookId } = Route.useParams(); const { appId, webhookId } = Route.useParams();
@ -44,69 +100,124 @@ function DeliveriesPage() {
const deliveries = deliveriesData ?? []; const deliveries = deliveriesData ?? [];
return ( return (
<div className="p-6 max-w-4xl"> <div className="min-h-full bg-gray-50/50">
<div className="flex items-center gap-3 mb-6"> {/* Header */}
<Link to="/apps/$appId/webhooks/" params={{ appId }}> <div className="bg-white border-b border-gray-200 px-8 h-14 flex items-center justify-between">
<Button variant="ghost" size="sm"> <div className="flex items-center gap-3">
<ArrowLeft className="h-4 w-4 mr-1" /> Webhooks <Link
</Button> to="/apps/$appId/webhooks/"
</Link> params={{ appId }}
<h1 className="text-xl font-semibold">Delivery log</h1> className="flex items-center gap-1.5 text-[12px] text-gray-400 hover:text-gray-700 transition-colors"
>
<ArrowLeft size={13} strokeWidth={2} />
<span>Webhooks</span>
</Link>
<span className="text-gray-200 text-sm">/</span>
<div className="flex items-center gap-2">
<ScrollText size={14} className="text-gray-400" strokeWidth={1.75} />
<h1 className="text-[14px] font-semibold text-gray-900">Delivery Log</h1>
</div>
{!isPending && deliveries.length > 0 && (
<span className="text-[11px] font-medium text-gray-400 bg-gray-100 rounded-full px-2 py-0.5">
{deliveries.length}
</span>
)}
</div>
</div> </div>
{(isPending as boolean) && ( <div className="px-8 py-8">
<p className="text-sm text-muted-foreground">Loading…</p> {/* Loading */}
)} {(isPending as boolean) && (
<div className="space-y-2.5">
{!(isPending as boolean) && deliveries.length === 0 && ( <SkeletonRow />
<p className="text-sm text-muted-foreground">No deliveries yet.</p> <SkeletonRow />
)} <SkeletonRow />
<div className="space-y-2">
{deliveries.map((d) => (
<div
key={d.id}
className="border rounded-lg p-3 flex items-start gap-3"
>
<div className="mt-0.5 shrink-0">{STATUS_ICON[d.status]}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span
className={cn(
"text-xs px-2 py-0.5 rounded border font-medium",
STATUS_BADGE[d.status],
)}
>
{d.status}
</span>
<Badge variant="secondary" className="text-xs">
{d.eventType}
</Badge>
{d.httpStatus && (
<span className="text-xs text-muted-foreground">
HTTP {d.httpStatus}
</span>
)}
<span className="text-xs text-muted-foreground">
attempt {d.attempt}
</span>
</div>
<div className="flex items-center gap-4 mt-1 text-xs text-muted-foreground">
<span>{new Date(d.createdAt).toLocaleString()}</span>
{d.deliveredAt && (
<span>
delivered {new Date(d.deliveredAt).toLocaleString()}
</span>
)}
{d.nextAttemptAt && d.status === "pending" && (
<span>
retry at {new Date(d.nextAttemptAt).toLocaleString()}
</span>
)}
</div>
</div>
</div> </div>
))} )}
{/* Empty state */}
{!(isPending as boolean) && deliveries.length === 0 && (
<div className="flex flex-col items-center justify-center py-24 text-center">
<div className="mb-4 flex items-center justify-center w-14 h-14 rounded-2xl bg-gray-100 text-gray-300">
<ScrollText size={28} strokeWidth={1.25} />
</div>
<h2 className="text-[15px] font-semibold text-gray-800 mb-1">No deliveries yet</h2>
<p className="text-[13px] text-gray-400 max-w-xs leading-relaxed">
Deliveries appear here once events are triggered for this endpoint.
</p>
</div>
)}
{/* Delivery rows */}
<div className="space-y-2.5">
{deliveries.map((d) => {
const cfg = STATUS_CONFIG[d.status];
return (
<div
key={d.id}
className="group rounded-xl border border-gray-100 bg-white overflow-hidden hover:shadow-sm transition-shadow"
>
<div className="flex">
<div className={cn("w-0.5 shrink-0", cfg.bar)} />
<div className="flex-1 px-4 py-3.5">
<div className="flex items-center justify-between gap-4">
{/* Left: status + event + http */}
<div className="flex items-center gap-2 flex-wrap min-w-0">
<span
className={cn(
"inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded-full border",
cfg.pill,
)}
>
{cfg.icon}
{cfg.label}
</span>
<span className="font-mono text-[11px] px-1.5 py-0.5 rounded-md border border-gray-100 bg-gray-50 text-gray-600">
{d.eventType}
</span>
{d.httpStatus && (
<span className="font-mono text-[11px] text-gray-400">
HTTP {d.httpStatus}
</span>
)}
<span className="text-[11px] text-gray-300">
·
</span>
<span className="text-[11px] text-gray-400">
attempt {d.attempt}
</span>
</div>
{/* Right: created time */}
<span
className="text-[11px] text-gray-400 shrink-0 tabular-nums"
title={new Date(d.createdAt).toLocaleString()}
>
{timeAgo(d.createdAt)}
</span>
</div>
{/* Meta row */}
{(d.deliveredAt || (d.nextAttemptAt && d.status === "pending")) && (
<div className="mt-1.5 flex items-center gap-3 text-[11px] text-gray-400">
{d.deliveredAt && (
<span title={new Date(d.deliveredAt).toLocaleString()}>
delivered {timeAgo(d.deliveredAt)}
</span>
)}
{d.nextAttemptAt && d.status === "pending" && (
<span className="text-amber-500">
retry in {timeUntil(d.nextAttemptAt)}
</span>
)}
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</div> </div>
</div> </div>
); );

View file

@ -9,12 +9,13 @@ import {
CheckCircle2, CheckCircle2,
XCircle, XCircle,
TestTube, TestTube,
ScrollText,
Zap,
} from "lucide-react"; } from "lucide-react";
import { webhooksQuery } from "#/lib/query"; import { webhooksQuery } from "#/lib/query";
import { Button } from "#/components/ui/button"; import { Button } from "#/components/ui/button";
import { Input } from "#/components/ui/input"; import { Input } from "#/components/ui/input";
import { Label } from "#/components/ui/label"; import { Label } from "#/components/ui/label";
import { Badge } from "#/components/ui/badge";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -40,6 +41,12 @@ interface WebhookEndpoint {
secret?: string; secret?: string;
} }
type TestResult = {
ok: boolean;
httpStatus: number | null;
error: string | null;
};
const ALL_EVENTS = [ const ALL_EVENTS = [
"session.status", "session.status",
"session.deleted", "session.deleted",
@ -53,6 +60,54 @@ const ALL_EVENTS = [
"contact.updated", "contact.updated",
]; ];
function formatDate(iso: string) {
const d = new Date(iso);
const now = Date.now();
const diff = now - d.getTime();
const mins = Math.floor(diff / 60000);
const hrs = Math.floor(mins / 60);
const days = Math.floor(hrs / 24);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
if (hrs < 24) return `${hrs}h ago`;
if (days < 30) return `${days}d ago`;
return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
}
function splitUrl(raw: string) {
try {
const u = new URL(raw);
return { proto: u.protocol + "//", rest: u.host + u.pathname + u.search };
} catch {
return { proto: "", rest: raw };
}
}
function SkeletonCard() {
return (
<div className="rounded-xl border border-gray-100 bg-white p-5 animate-pulse">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-3">
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-gray-200" />
<div className="h-4 w-72 rounded bg-gray-100" />
</div>
<div className="flex gap-1.5">
<div className="h-5 w-20 rounded-full bg-gray-100" />
<div className="h-5 w-24 rounded-full bg-gray-100" />
<div className="h-5 w-16 rounded-full bg-gray-100" />
</div>
</div>
<div className="flex gap-2">
<div className="h-8 w-16 rounded-lg bg-gray-100" />
<div className="h-8 w-10 rounded-lg bg-gray-100" />
<div className="h-8 w-8 rounded-lg bg-gray-100" />
</div>
</div>
</div>
);
}
function WebhooksPage() { function WebhooksPage() {
const { appId } = Route.useParams(); const { appId } = Route.useParams();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -73,11 +128,7 @@ function WebhooksPage() {
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [testingId, setTestingId] = useState<string | null>(null); const [testingId, setTestingId] = useState<string | null>(null);
const [testResult, setTestResult] = useState<{ const [testResults, setTestResults] = useState<Record<string, TestResult>>({});
ok: boolean;
httpStatus: number | null;
error: string | null;
} | null>(null);
function toggleEvent(ev: string) { function toggleEvent(ev: string) {
setNewEvents((prev) => setNewEvents((prev) =>
@ -96,7 +147,16 @@ function WebhooksPage() {
credentials: "include", credentials: "include",
body: JSON.stringify({ app_id: appId, url: newUrl, events: newEvents }), body: JSON.stringify({ app_id: appId, url: newUrl, events: newEvents }),
}); });
if (!res.ok) throw new Error(await res.text()); if (!res.ok) {
const body = await res.text();
try {
const j = JSON.parse(body);
throw new Error(j.error ?? j.message ?? body);
} catch (parseErr) {
if (parseErr instanceof SyntaxError) throw new Error(body);
throw parseErr;
}
}
const data = await res.json(); const data = await res.json();
setCreatedSecret(data.secret); setCreatedSecret(data.secret);
queryClient.invalidateQueries({ queryKey: ["webhooks", appId] }); queryClient.invalidateQueries({ queryKey: ["webhooks", appId] });
@ -115,10 +175,7 @@ function WebhooksPage() {
try { try {
await fetch( await fetch(
`/api/v1/admin/webhooks/${deleteId}?app_id=${encodeURIComponent(appId)}`, `/api/v1/admin/webhooks/${deleteId}?app_id=${encodeURIComponent(appId)}`,
{ { method: "DELETE", credentials: "include" },
method: "DELETE",
credentials: "include",
},
); );
queryClient.invalidateQueries({ queryKey: ["webhooks", appId] }); queryClient.invalidateQueries({ queryKey: ["webhooks", appId] });
} finally { } finally {
@ -129,7 +186,11 @@ function WebhooksPage() {
async function testWebhook(id: string) { async function testWebhook(id: string) {
setTestingId(id); setTestingId(id);
setTestResult(null); setTestResults((prev) => {
const next = { ...prev };
delete next[id];
return next;
});
try { try {
const res = await fetch(`/api/v1/admin/webhooks/${id}/test`, { const res = await fetch(`/api/v1/admin/webhooks/${id}/test`, {
method: "POST", method: "POST",
@ -137,17 +198,28 @@ function WebhooksPage() {
credentials: "include", credentials: "include",
body: JSON.stringify({ app_id: appId }), body: JSON.stringify({ app_id: appId }),
}); });
setTestResult(await res.json()); const result: TestResult = await res.json();
setTestResults((prev) => ({ ...prev, [id]: result }));
} catch { } catch {
setTestResult({ ok: false, httpStatus: null, error: "Request failed" }); setTestResults((prev) => ({
...prev,
[id]: { ok: false, httpStatus: null, error: "Request failed" },
}));
} finally { } finally {
setTestingId(null); setTestingId(null);
} }
} }
const isPrivateUrl = (url: string) => {
try {
const h = new URL(url).hostname;
return /^(localhost|127\.\d+\.\d+\.\d+|0\.0\.0\.0|::1|10\.\d+\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|192\.168\.\d+\.\d+|169\.254\.\d+\.\d+)$/i.test(h);
} catch { return false; }
};
return ( return (
<div className="min-h-full"> <div className="min-h-full bg-gray-50/50">
{/* Page header */} {/* Header */}
<div className="bg-white border-b border-gray-200 px-8 h-14 flex items-center justify-between"> <div className="bg-white border-b border-gray-200 px-8 h-14 flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Webhook size={15} className="text-gray-400" strokeWidth={1.75} /> <Webhook size={15} className="text-gray-400" strokeWidth={1.75} />
@ -160,223 +232,307 @@ function WebhooksPage() {
</div> </div>
<Button <Button
size="sm" size="sm"
onClick={() => { onClick={() => { setNewOpen(true); setCreatedSecret(null); }}
setNewOpen(true);
setCreatedSecret(null);
}}
className="h-8 px-3 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white gap-1.5" className="h-8 px-3 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white gap-1.5"
> >
<Plus size={13} strokeWidth={2.5} /> Add endpoint <Plus size={13} strokeWidth={2.5} /> Add endpoint
</Button> </Button>
</div> </div>
<div className="px-8 py-6 max-w-4xl animate-fade-up"> <div className="px-8 py-8">
{/* Loading skeletons */}
{(isPending as boolean) && ( {(isPending as boolean) && (
<p className="text-sm text-muted-foreground">Loading…</p> <div className="space-y-3">
)} <SkeletonCard />
<SkeletonCard />
{!(isPending as boolean) && webhooks.length === 0 && (
<p className="text-sm text-muted-foreground">
No webhook endpoints yet.
</p>
)}
<div className="space-y-3">
{webhooks.map((wh) => (
<div
key={wh.id}
className="border rounded-lg p-4 flex items-start justify-between gap-4"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{wh.enabled ? (
<CheckCircle2 className="h-4 w-4 text-emerald-600 shrink-0" />
) : (
<XCircle className="h-4 w-4 text-red-500 shrink-0" />
)}
<span className="font-mono text-sm truncate">{wh.url}</span>
{wh.unhealthySince && (
<Badge variant="destructive" className="shrink-0">
Unhealthy
</Badge>
)}
</div>
<div className="flex flex-wrap gap-1 mt-1">
{(wh.events.length === 0 ? ["all events"] : wh.events).map(
(ev) => (
<Badge key={ev} variant="secondary" className="text-xs">
{ev}
</Badge>
),
)}
</div>
{wh.consecutiveFailures > 0 && (
<p className="text-xs text-red-500 mt-1">
{wh.consecutiveFailures} consecutive failure(s)
</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
variant="outline"
size="sm"
disabled={testingId === wh.id}
onClick={() => testWebhook(wh.id)}
>
<TestTube className="h-3 w-3 mr-1" />
{testingId === wh.id ? "Testing…" : "Test"}
</Button>
<Link
to="/apps/$appId/webhooks/$webhookId/deliveries"
params={{ appId, webhookId: wh.id }}
>
<Button variant="outline" size="sm">
Log
</Button>
</Link>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteId(wh.id)}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</div>
))}
</div>
{testResult && (
<div
className={cn(
"mt-4 p-3 rounded-lg text-sm border",
testResult.ok
? "border-emerald-200 bg-emerald-50 text-emerald-800"
: "border-red-200 bg-red-50 text-red-800",
)}
>
{testResult.ok ? (
<>Test delivered — HTTP {testResult.httpStatus}</>
) : (
<>
Test failed —{" "}
{testResult.error ?? `HTTP ${testResult.httpStatus}`}
</>
)}
</div> </div>
)} )}
{/* Create dialog */} {/* Empty state */}
<Dialog {!(isPending as boolean) && webhooks.length === 0 && (
open={newOpen} <div className="flex flex-col items-center justify-center py-24 text-center">
onOpenChange={(o) => { <div className="mb-5 flex items-center justify-center w-16 h-16 rounded-2xl bg-gray-100 text-gray-300">
setNewOpen(o); <Webhook size={32} strokeWidth={1.25} />
if (!o) setCreatedSecret(null); </div>
}} <h2 className="text-[15px] font-semibold text-gray-800 mb-1">No endpoints yet</h2>
> <p className="text-[13px] text-gray-400 mb-6 max-w-xs leading-relaxed">
<DialogContent className="max-w-lg"> Add a webhook endpoint to start receiving real-time event notifications via signed HTTP POST.
<DialogHeader> </p>
<DialogTitle>Add webhook endpoint</DialogTitle> <Button
<DialogDescription> size="sm"
Events will be delivered via signed HTTP POST. onClick={() => { setNewOpen(true); setCreatedSecret(null); }}
</DialogDescription> className="h-8 px-4 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white gap-1.5"
</DialogHeader> >
{createdSecret ? ( <Plus size={13} strokeWidth={2.5} /> Add endpoint
<div className="space-y-3"> </Button>
<p className="text-sm text-emerald-700 font-medium"> </div>
Endpoint created. Copy your signing secret — it won't be shown )}
again.
</p> {/* Endpoint cards */}
<code className="block bg-muted rounded p-3 text-xs break-all select-all"> <div className="space-y-3">
{createdSecret} {webhooks.map((wh) => {
</code> const { proto, rest } = splitUrl(wh.url);
<Button const healthy = wh.enabled && !wh.unhealthySince;
onClick={() => { const result = testResults[wh.id];
setNewOpen(false);
setCreatedSecret(null); return (
}} <div
> key={wh.id}
Done className={cn(
</Button> "group rounded-xl border bg-white overflow-hidden transition-shadow hover:shadow-sm",
</div> healthy ? "border-gray-100" : "border-red-100",
) : ( )}
<form onSubmit={createWebhook} className="space-y-4"> >
<div className="space-y-1"> {/* Left accent bar */}
<Label>URL</Label> <div className="flex">
<Input <div
placeholder="https://example.com/webhook" className={cn(
value={newUrl} "w-0.5 shrink-0 rounded-l-xl",
onChange={(e) => setNewUrl(e.target.value)} healthy ? "bg-emerald-400" : "bg-red-400",
required
/>
{newUrl.startsWith("http://") &&
!newUrl.startsWith("https://") && (
<p className="text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded px-2 py-1 mt-1">
Warning: HTTP endpoints receive webhook payloads
unencrypted. Use HTTPS in production.
</p>
)} )}
</div> />
<div className="space-y-1"> <div className="flex-1 p-5">
<Label>Events (empty = all)</Label> {/* Top row */}
<div className="flex flex-wrap gap-2 mt-1"> <div className="flex items-start justify-between gap-4">
{ALL_EVENTS.map((ev) => ( <div className="flex-1 min-w-0">
<button {/* Status + URL */}
key={ev} <div className="flex items-center gap-2 mb-2.5">
type="button" <span className="relative flex h-2 w-2 shrink-0">
onClick={() => toggleEvent(ev)} {healthy ? (
<>
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-50" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500" />
</>
) : (
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-500" />
)}
</span>
<span className="font-mono text-[13px] text-gray-800 truncate">
<span className="text-gray-400">{proto}</span>
{rest}
</span>
</div>
{/* Events */}
<div className="flex flex-wrap gap-1 mb-2">
{(wh.events.length === 0
? [{ label: "all events", all: true }]
: wh.events.map((ev) => ({ label: ev, all: false }))
).map(({ label, all }) => (
<span
key={label}
className={cn(
"inline-flex items-center font-mono text-[10px] px-1.5 py-0.5 rounded-md border",
all
? "text-violet-600 bg-violet-50 border-violet-100"
: "text-gray-500 bg-gray-50 border-gray-100",
)}
>
{label}
</span>
))}
</div>
{/* Meta row */}
<div className="flex items-center gap-3 text-[11px] text-gray-400">
<span>Added {formatDate(wh.createdAt)}</span>
{wh.consecutiveFailures > 0 && (
<span className="flex items-center gap-1 text-red-500">
<AlertCircle size={10} />
{wh.consecutiveFailures} failure{wh.consecutiveFailures !== 1 ? "s" : ""}
</span>
)}
{!wh.enabled && (
<span className="flex items-center gap-1 text-gray-400">
<XCircle size={10} />
Disabled
</span>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-1.5 shrink-0 pt-0.5">
<Button
variant="outline"
size="sm"
disabled={testingId === wh.id}
onClick={() => testWebhook(wh.id)}
className="h-8 px-3 text-[12px] gap-1.5 border-gray-200 text-gray-700 hover:bg-gray-50"
>
<Zap size={11} className={testingId === wh.id ? "animate-pulse" : ""} />
{testingId === wh.id ? "Testing…" : "Test"}
</Button>
<Link
to="/apps/$appId/webhooks/$webhookId/deliveries"
params={{ appId, webhookId: wh.id }}
>
<Button
variant="ghost"
size="sm"
className="h-8 px-3 text-[12px] gap-1.5 text-gray-500 hover:text-gray-800 hover:bg-gray-50"
>
<ScrollText size={11} />
Log
</Button>
</Link>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteId(wh.id)}
className="h-8 w-8 p-0 text-gray-300 hover:text-red-500 hover:bg-red-50"
>
<Trash2 size={13} />
</Button>
</div>
</div>
{/* Inline test result */}
{result && (
<div
className={cn( className={cn(
"text-xs px-2 py-1 rounded border transition-colors", "mt-3 flex items-center gap-2 rounded-lg px-3 py-2 text-[12px] border",
newEvents.includes(ev) result.ok
? "bg-primary text-primary-foreground border-primary" ? "bg-emerald-50 border-emerald-100 text-emerald-700"
: "border-border hover:bg-muted", : "bg-red-50 border-red-100 text-red-700",
)} )}
> >
{ev} {result.ok ? (
</button> <CheckCircle2 size={13} className="shrink-0" />
))} ) : (
<XCircle size={13} className="shrink-0" />
)}
<span>
{result.ok
? `Delivered — HTTP ${result.httpStatus}`
: `Failed — ${result.error ?? `HTTP ${result.httpStatus}`}`}
</span>
</div>
)}
</div> </div>
</div> </div>
{createError && ( </div>
<p className="text-sm text-red-500">{createError}</p> );
)} })}
<Button type="submit" disabled={creating}> </div>
{creating ? "Creating…" : "Create"} </div>
</Button>
</form>
)}
</DialogContent>
</Dialog>
{/* Delete confirm */} {/* Create dialog */}
<Dialog <Dialog
open={!!deleteId} open={newOpen}
onOpenChange={(o) => { onOpenChange={(o) => { setNewOpen(o); if (!o) setCreatedSecret(null); }}
if (!o) setDeleteId(null); >
}} <DialogContent className="max-w-lg">
> <DialogHeader>
<DialogContent> <DialogTitle>Add webhook endpoint</DialogTitle>
<DialogHeader> <DialogDescription>
<DialogTitle>Delete endpoint</DialogTitle> Events will be delivered via signed HTTP POST.
<DialogDescription> </DialogDescription>
This removes the endpoint and all delivery history. </DialogHeader>
</DialogDescription> {createdSecret ? (
</DialogHeader> <div className="space-y-3">
<div className="flex gap-2 justify-end"> <div className="flex items-start gap-2 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2.5 text-sm text-emerald-700">
<Button variant="outline" onClick={() => setDeleteId(null)}> <CheckCircle2 size={15} className="mt-0.5 shrink-0" />
Cancel <span>Endpoint created. Copy your signing secret — it won't be shown again.</span>
</Button> </div>
<code className="block bg-muted rounded-lg p-3 text-xs break-all select-all font-mono border">
{createdSecret}
</code>
<Button <Button
variant="destructive" onClick={() => { setNewOpen(false); setCreatedSecret(null); }}
disabled={deleting} className="bg-zinc-900 hover:bg-zinc-800"
onClick={deleteWebhook}
> >
{deleting ? "Deleting…" : "Delete"} Done
</Button> </Button>
</div> </div>
</DialogContent> ) : (
</Dialog> <form onSubmit={createWebhook} className="space-y-4">
</div> <div className="space-y-1.5">
<Label>URL</Label>
<Input
placeholder="https://example.com/webhook"
value={newUrl}
onChange={(e) => setNewUrl(e.target.value)}
className="font-mono text-sm"
required
/>
{newUrl.startsWith("http://") && !newUrl.startsWith("https://") && (
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 px-2.5 py-1.5 text-xs text-amber-700">
<AlertCircle size={12} className="mt-0.5 shrink-0" />
<span>HTTP endpoints receive webhook payloads unencrypted. Use HTTPS in production.</span>
</div>
)}
{isPrivateUrl(newUrl) && (
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 px-2.5 py-1.5 text-xs text-amber-700">
<AlertCircle size={12} className="mt-0.5 shrink-0" />
<span>URL targets a private or local address. Deliveries will only work if the server can reach this host.</span>
</div>
)}
</div>
<div className="space-y-1.5">
<Label>Events <span className="text-gray-400 font-normal">(empty = all)</span></Label>
<div className="flex flex-wrap gap-1.5 mt-1">
{ALL_EVENTS.map((ev) => (
<button
key={ev}
type="button"
onClick={() => toggleEvent(ev)}
className={cn(
"font-mono text-[11px] px-2 py-1 rounded-md border transition-colors",
newEvents.includes(ev)
? "bg-zinc-900 text-white border-zinc-900"
: "border-gray-200 text-gray-600 hover:bg-gray-50",
)}
>
{ev}
</button>
))}
</div>
</div>
{createError && (
<div className="flex items-start gap-2 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>{createError}</span>
</div>
)}
<Button
type="submit"
disabled={creating}
className="bg-zinc-900 hover:bg-zinc-800 w-full"
>
{creating ? "Creating…" : "Create endpoint"}
</Button>
</form>
)}
</DialogContent>
</Dialog>
{/* Delete confirm */}
<Dialog
open={!!deleteId}
onOpenChange={(o) => { if (!o) setDeleteId(null); }}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete endpoint</DialogTitle>
<DialogDescription>
This removes the endpoint and all delivery history.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={() => setDeleteId(null)}>
Cancel
</Button>
<Button
variant="destructive"
disabled={deleting}
onClick={deleteWebhook}
>
{deleting ? "Deleting…" : "Delete"}
</Button>
</div>
</DialogContent>
</Dialog>
</div> </div>
); );
} }

View file

@ -6,11 +6,14 @@ import {
Webhook, Webhook,
RefreshCw, RefreshCw,
Server, Server,
ArrowDown,
ArrowUp,
Layers,
AlertTriangle,
RotateCcw,
} from "lucide-react"; } from "lucide-react";
import { monitoringStatsQuery } from "#/lib/query"; import { monitoringStatsQuery } from "#/lib/query";
import { getSessionFn } from "#/server/auth"; import { getSessionFn } from "#/server/auth";
import { Button } from "#/components/ui/button";
import { Badge } from "#/components/ui/badge";
import { cn } from "#/lib/utils"; import { cn } from "#/lib/utils";
export const Route = createFileRoute("/monitoring")({ export const Route = createFileRoute("/monitoring")({
@ -47,54 +50,190 @@ interface MonitoringStats {
queues: { send: number; webhook: number }; queues: { send: number; webhook: number };
} }
const SESSION_STATUS_STYLE: Record<string, string> = { const SESSION_STATUS_STYLE: Record<
CONNECTED: "bg-emerald-50 text-emerald-700 border-emerald-200", string,
DISCONNECTED: "bg-amber-50 text-amber-700 border-amber-200", { badge: string; dot: string; label: string } | undefined
DEAD: "bg-red-50 text-red-700 border-red-200", > = {
LOGGED_OUT: "bg-gray-50 text-gray-600 border-gray-200", CONNECTED: {
PENDING_QR: "bg-blue-50 text-blue-700 border-blue-200", badge: "bg-emerald-50 text-emerald-700 border-emerald-200",
PENDING_PAIRING: "bg-blue-50 text-blue-700 border-blue-200", dot: "bg-emerald-500",
CONNECTING: "bg-sky-50 text-sky-700 border-sky-200", label: "Connected",
},
DISCONNECTED: {
badge: "bg-amber-50 text-amber-700 border-amber-200",
dot: "bg-amber-500",
label: "Disconnected",
},
DEAD: {
badge: "bg-red-50 text-red-700 border-red-200",
dot: "bg-red-500",
label: "Dead",
},
LOGGED_OUT: {
badge: "bg-gray-50 text-gray-600 border-gray-200",
dot: "bg-gray-400",
label: "Logged out",
},
PENDING_QR: {
badge: "bg-blue-50 text-blue-700 border-blue-200",
dot: "bg-blue-500",
label: "Pending QR",
},
PENDING_PAIRING: {
badge: "bg-blue-50 text-blue-700 border-blue-200",
dot: "bg-blue-500",
label: "Pending Pairing",
},
CONNECTING: {
badge: "bg-sky-50 text-sky-700 border-sky-200",
dot: "bg-sky-500",
label: "Connecting",
},
}; };
function StatCard({ function KpiCard({
label, label,
value, value,
icon: Icon,
accent,
sub, sub,
}: { }: {
label: string; label: string;
value: string | number; value: string | number;
icon: React.ElementType;
accent: string;
sub?: string; sub?: string;
}) { }) {
return ( return (
<div className="border rounded-lg p-4"> <div className="bg-white border border-gray-200 rounded-xl p-4 flex flex-col gap-3">
<p className="text-xs text-muted-foreground mb-1">{label}</p> <div className="flex items-center justify-between">
<p className="text-2xl font-bold">{value}</p> <span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">
{sub && <p className="text-xs text-muted-foreground mt-1">{sub}</p>} {label}
</span>
<div className={cn("w-7 h-7 rounded-lg flex items-center justify-center", accent)}>
<Icon size={13} strokeWidth={2} />
</div>
</div>
<div>
<p className="text-2xl font-bold text-gray-900 leading-none">{value}</p>
{sub && (
<p className="text-[11px] text-gray-400 mt-1">{sub}</p>
)}
</div>
</div> </div>
); );
} }
function MessageBar({ row, max }: { row: HourlyRow; max: number }) { function QueueCard({
const pct = max > 0 ? (row.count / max) * 100 : 0; label,
depth,
}: {
label: string;
depth: number;
}) {
const isHealthy = depth === 0;
return ( return (
<div className="flex items-center gap-2 text-xs"> <div className="bg-white border border-gray-200 rounded-xl p-4">
<span className="w-12 text-right text-muted-foreground shrink-0"> <div className="flex items-center justify-between mb-3">
{new Date(row.hour).getHours()}:00 <span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">
</span> {label}
<div className="flex-1 h-4 bg-muted rounded overflow-hidden"> </span>
<div <div
className={cn( className={cn(
"h-full rounded", "flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded-full border",
row.direction === "in" ? "bg-blue-400" : "bg-emerald-400", isHealthy
? "bg-emerald-50 text-emerald-700 border-emerald-200"
: "bg-amber-50 text-amber-700 border-amber-200",
)} )}
style={{ width: `${pct}%` }} >
/> <div
className={cn(
"w-1.5 h-1.5 rounded-full",
isHealthy ? "bg-emerald-500" : "bg-amber-500",
)}
/>
{isHealthy ? "Healthy" : "Backlog"}
</div>
</div> </div>
<span className="w-8 text-muted-foreground">{row.count}</span> <p className="text-2xl font-bold text-gray-900 leading-none">
<Badge variant="outline" className="text-xs py-0"> {depth}
{row.direction === "in" ? "↓" : "↑"} </p>
</Badge> <p className="text-[11px] text-gray-400 mt-1">pending jobs</p>
</div>
);
}
function MessageChart({ rows }: { rows: HourlyRow[] }) {
if (rows.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-10 text-center">
<MessageSquare size={24} className="text-gray-200 mb-2" />
<p className="text-[12px] text-gray-400">No message data in the last 24h</p>
</div>
);
}
const hourMap: Record<string, { in: number; out: number }> = {};
for (const row of rows) {
const h = new Date(row.hour).getHours();
const key = String(h);
if (!Object.hasOwn(hourMap, key)) hourMap[key] = { in: 0, out: 0 };
if (row.direction === "in") hourMap[key].in += row.count;
else hourMap[key].out += row.count;
}
const hours = Object.keys(hourMap).sort((a, b) => Number(a) - Number(b));
const maxVal = Math.max(
1,
...hours.flatMap((h) => [hourMap[h].in, hourMap[h].out]),
);
return (
<div className="space-y-3">
{hours.map((h) => {
const d = hourMap[h];
const inPct = (d.in / maxVal) * 100;
const outPct = (d.out / maxVal) * 100;
return (
<div key={h} className="flex items-start gap-3">
{/* Hour label — vertically centered across both bars */}
<span className="text-[11px] font-mono text-gray-400 w-10 text-right shrink-0 mt-1.5">
{String(h).padStart(2, "0")}:00
</span>
{/* Bar group */}
<div className="flex-1 space-y-1">
{/* Inbound */}
<div className="flex items-center gap-2">
<span className="text-[10px] text-blue-400 shrink-0 w-3 leading-none">↓</span>
<div className="flex-1 h-2.5 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-blue-400 rounded-full transition-all duration-500"
style={{ width: `${inPct}%` }}
/>
</div>
<span className="text-[11px] font-semibold text-blue-500 tabular-nums w-6 text-right shrink-0">
{d.in}
</span>
</div>
{/* Outbound */}
<div className="flex items-center gap-2">
<span className="text-[10px] text-emerald-400 shrink-0 w-3 leading-none">↑</span>
<div className="flex-1 h-2.5 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-emerald-400 rounded-full transition-all duration-500"
style={{ width: `${outPct}%` }}
/>
</div>
<span className="text-[11px] font-semibold text-emerald-500 tabular-nums w-6 text-right shrink-0">
{d.out}
</span>
</div>
</div>
</div>
);
})}
</div> </div>
); );
} }
@ -104,11 +243,10 @@ function MonitoringPage() {
data: stats, data: stats,
isPending, isPending,
refetch, refetch,
isFetching,
} = useQuery<MonitoringStats>(monitoringStatsQuery); } = useQuery<MonitoringStats>(monitoringStatsQuery);
const hourlyRows = stats?.messages.hourly ?? []; const hourlyRows = stats?.messages.hourly ?? [];
const maxHourlyCount = Math.max(1, ...hourlyRows.map((r) => r.count));
const totalIn24h = hourlyRows const totalIn24h = hourlyRows
.filter((r) => r.direction === "in") .filter((r) => r.direction === "in")
.reduce((s, r) => s + r.count, 0); .reduce((s, r) => s + r.count, 0);
@ -118,8 +256,7 @@ function MonitoringPage() {
const webhookSuccess = stats?.webhooks.find((w) => w.status === "success"); const webhookSuccess = stats?.webhooks.find((w) => w.status === "success");
const webhookFailed = stats?.webhooks.find((w) => w.status === "failed"); const webhookFailed = stats?.webhooks.find((w) => w.status === "failed");
const webhookTotal = const webhookTotal = (webhookSuccess?.count ?? 0) + (webhookFailed?.count ?? 0);
(webhookSuccess?.count ?? 0) + (webhookFailed?.count ?? 0);
const webhookSuccessRate = const webhookSuccessRate =
webhookTotal > 0 webhookTotal > 0
? Math.round(((webhookSuccess?.count ?? 0) / webhookTotal) * 100) ? Math.round(((webhookSuccess?.count ?? 0) / webhookTotal) * 100)
@ -127,162 +264,293 @@ function MonitoringPage() {
const connectedSessions = const connectedSessions =
stats?.sessions.find((s) => s.status === "CONNECTED")?.count ?? 0; stats?.sessions.find((s) => s.status === "CONNECTED")?.count ?? 0;
const totalSessions =
stats?.sessions.reduce((s, r) => s + r.count, 0) ?? 0;
return ( return (
<div className="p-6 max-w-5xl"> <div className="min-h-full">
<div className="flex items-center justify-between mb-6"> {/* Page header — matches sessions/audit-log */}
<div className="bg-white border-b border-gray-200 px-8 h-14 flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Activity className="h-5 w-5" /> <Activity size={16} className="text-gray-400" strokeWidth={1.75} />
<h1 className="text-xl font-semibold">Monitoring</h1> <h1 className="text-[14px] font-semibold text-gray-900">Monitoring</h1>
{!(isPending as boolean) && (
<span className="text-[11px] font-medium text-gray-400 bg-gray-100 rounded-full px-2 py-0.5">
last 24h
</span>
)}
</div> </div>
<Button variant="outline" size="sm" onClick={() => refetch()}> <button
<RefreshCw className="h-3.5 w-3.5 mr-1" /> Refresh onClick={() => refetch()}
</Button> className={cn(
"p-1.5 rounded-md text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors",
isFetching && "animate-spin",
)}
title="Refresh"
disabled={isFetching}
>
<RefreshCw size={14} strokeWidth={1.75} />
</button>
</div> </div>
{(isPending as boolean) && ( {/* Content */}
<p className="text-sm text-muted-foreground mb-4">Loading…</p> <div className="px-8 py-6 max-w-5xl animate-fade-up space-y-4">
)}
{/* Summary stats */} {(isPending as boolean) && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6"> <div className="flex items-center gap-2 text-[13px] text-gray-400">
<StatCard label="Messages received (24h)" value={totalIn24h} /> <RefreshCw size={13} className="animate-spin" />
<StatCard label="Messages sent (24h)" value={totalOut24h} /> Loading stats…
<StatCard label="Connected sessions" value={connectedSessions} />
<StatCard
label="Webhook success rate (24h)"
value={webhookSuccessRate !== null ? `${webhookSuccessRate}%` : "—"}
sub={webhookTotal > 0 ? `${webhookTotal} total` : undefined}
/>
</div>
{/* Queue depths */}
<div className="grid grid-cols-2 gap-3 mb-6">
<StatCard
label="Send queue depth"
value={stats?.queues.send ?? "—"}
sub="pending jobs"
/>
<StatCard
label="Webhook queue depth"
value={stats?.queues.webhook ?? "—"}
sub="pending jobs"
/>
</div>
{/* Messages per hour */}
<div className="border rounded-lg p-4 mb-6">
<div className="flex items-center gap-2 mb-3">
<MessageSquare className="h-4 w-4" />
<h2 className="font-medium text-sm">Messages per hour (last 24h)</h2>
</div>
{hourlyRows.length === 0 ? (
<p className="text-xs text-muted-foreground">No data</p>
) : (
<div className="space-y-1">
{hourlyRows.map((row, i) => (
<MessageBar key={i} row={row} max={maxHourlyCount} />
))}
</div> </div>
)} )}
<div className="flex gap-4 mt-3 text-xs text-muted-foreground">
<span>
<span className="inline-block w-2 h-2 rounded-full bg-blue-400 mr-1" />
Inbound
</span>
<span>
<span className="inline-block w-2 h-2 rounded-full bg-emerald-400 mr-1" />
Outbound
</span>
</div>
</div>
{/* Session status */} {/* KPI cards */}
<div className="border rounded-lg p-4 mb-6"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="flex items-center gap-2 mb-3"> <KpiCard
<Server className="h-4 w-4" /> label="Received (24h)"
<h2 className="font-medium text-sm">Session status breakdown</h2> value={totalIn24h}
icon={ArrowDown}
accent="bg-blue-50 text-blue-500"
/>
<KpiCard
label="Sent (24h)"
value={totalOut24h}
icon={ArrowUp}
accent="bg-emerald-50 text-emerald-500"
/>
<KpiCard
label="Connected sessions"
value={connectedSessions}
icon={Server}
accent="bg-violet-50 text-violet-500"
sub={totalSessions > 0 ? `${totalSessions} total` : undefined}
/>
<KpiCard
label="Webhook success"
value={webhookSuccessRate !== null ? `${webhookSuccessRate}%` : "—"}
icon={Webhook}
accent={
webhookSuccessRate === null
? "bg-gray-100 text-gray-400"
: webhookSuccessRate >= 90
? "bg-emerald-50 text-emerald-500"
: "bg-amber-50 text-amber-500"
}
sub={webhookTotal > 0 ? `${webhookTotal} deliveries` : undefined}
/>
</div> </div>
{!stats?.sessions.length ? (
<p className="text-xs text-muted-foreground">No sessions</p> {/* Queue depths */}
) : ( <div className="grid grid-cols-2 gap-3">
<div className="flex flex-wrap gap-2"> <QueueCard label="Send queue" depth={stats?.queues.send ?? 0} />
{stats.sessions.map((s) => ( <QueueCard label="Webhook queue" depth={stats?.queues.webhook ?? 0} />
<div </div>
key={s.status}
className={cn( {/* Messages chart + Session breakdown side by side */}
"text-xs px-3 py-1.5 rounded border font-medium", <div className="grid grid-cols-[1fr_280px] gap-3">
SESSION_STATUS_STYLE[s.status] || "border-border", {/* Messages per hour */}
)} <div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
> <div className="px-4 py-3 border-b border-gray-100 flex items-center justify-between">
{s.status}: {s.count} <div className="flex items-center gap-2">
<MessageSquare size={13} className="text-gray-400" strokeWidth={1.75} />
<span className="text-[13px] font-semibold text-gray-900">
Messages per hour
</span>
</div> </div>
))} <div className="flex items-center gap-3 text-[11px] text-gray-400">
<span className="flex items-center gap-1">
<span className="inline-block w-2 h-2 rounded-full bg-blue-400" />
Inbound
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-2 h-2 rounded-full bg-emerald-400" />
Outbound
</span>
</div>
</div>
<div className="px-4 py-4">
<MessageChart rows={hourlyRows} />
</div>
</div> </div>
)}
</div>
{/* Webhook delivery stats */} {/* Session status */}
<div className="border rounded-lg p-4 mb-6"> <div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
<div className="flex items-center gap-2 mb-3"> <div className="px-4 py-3 border-b border-gray-100 flex items-center gap-2">
<Webhook className="h-4 w-4" /> <Server size={13} className="text-gray-400" strokeWidth={1.75} />
<h2 className="font-medium text-sm">Webhook deliveries (last 24h)</h2> <span className="text-[13px] font-semibold text-gray-900">
Session status
</span>
</div>
<div className="px-4 py-4">
{!stats?.sessions.length ? (
<div className="flex flex-col items-center justify-center py-6 text-center">
<Server size={20} className="text-gray-200 mb-2" />
<p className="text-[12px] text-gray-400">No sessions</p>
</div>
) : (
<div className="space-y-2">
{stats.sessions.map((s) => {
const style =
SESSION_STATUS_STYLE[s.status] ||
SESSION_STATUS_STYLE.DISCONNECTED;
const pct = totalSessions > 0 ? (s.count / totalSessions) * 100 : 0;
return (
<div key={s.status} className="space-y-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<div
className={cn(
"w-1.5 h-1.5 rounded-full flex-shrink-0",
style.dot,
)}
/>
<span className="text-[12px] text-gray-600">
{style.label}
</span>
</div>
<span className="text-[12px] font-semibold text-gray-900 tabular-nums">
{s.count}
</span>
</div>
<div className="h-1 bg-gray-100 rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all duration-500",
s.status === "CONNECTED"
? "bg-emerald-400"
: s.status === "DEAD"
? "bg-red-400"
: s.status === "DISCONNECTED"
? "bg-amber-400"
: "bg-gray-300",
)}
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div> </div>
{!stats?.webhooks.length ? (
<p className="text-xs text-muted-foreground">No deliveries</p> {/* Webhook deliveries */}
) : ( <div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
<div className="overflow-x-auto"> <div className="px-4 py-3 border-b border-gray-100 flex items-center gap-2">
<table className="w-full text-xs"> <Webhook size={13} className="text-gray-400" strokeWidth={1.75} />
<thead> <span className="text-[13px] font-semibold text-gray-900">
<tr className="text-muted-foreground"> Webhook deliveries
<th className="text-left pb-2">Status</th> </span>
<th className="text-right pb-2">Count</th> <span className="text-[11px] font-medium text-gray-400 bg-gray-100 rounded-full px-2 py-0.5 ml-1">
<th className="text-right pb-2">p50 latency</th> last 24h
<th className="text-right pb-2">p95 latency</th> </span>
</tr> </div>
</thead> {!stats?.webhooks.length ? (
<tbody> <div className="flex flex-col items-center justify-center py-10 text-center">
<Webhook size={24} className="text-gray-200 mb-2" />
<p className="text-[12px] text-gray-400">No deliveries in the last 24h</p>
</div>
) : (
<div>
{/* Table header */}
<div className="grid grid-cols-[1fr_100px_120px_120px] gap-4 px-4 py-2.5 border-b border-gray-100 bg-gray-50">
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">
Status
</span>
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide text-right">
Count
</span>
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide text-right">
p50 latency
</span>
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide text-right">
p95 latency
</span>
</div>
<div className="divide-y divide-gray-100">
{stats.webhooks.map((w) => ( {stats.webhooks.map((w) => (
<tr key={w.status} className="border-t"> <div
<td className="py-1.5 capitalize">{w.status}</td> key={w.status}
<td className="text-right">{w.count}</td> className="grid grid-cols-[1fr_100px_120px_120px] gap-4 px-4 py-3 items-center"
<td className="text-right"> >
<div className="flex items-center gap-2">
<div
className={cn(
"w-1.5 h-1.5 rounded-full",
w.status === "success" ? "bg-emerald-500" : "bg-red-400",
)}
/>
<span
className={cn(
"text-[11px] font-medium px-1.5 py-0.5 rounded-md border capitalize",
w.status === "success"
? "bg-emerald-50 text-emerald-700 border-emerald-200"
: "bg-red-50 text-red-700 border-red-200",
)}
>
{w.status}
</span>
</div>
<span className="text-[12px] font-semibold text-gray-900 text-right tabular-nums">
{w.count}
</span>
<span className="text-[12px] text-gray-500 text-right font-mono tabular-nums">
{w.p50_ms != null ? `${Math.round(w.p50_ms)}ms` : "—"} {w.p50_ms != null ? `${Math.round(w.p50_ms)}ms` : "—"}
</td> </span>
<td className="text-right"> <span className="text-[12px] text-gray-500 text-right font-mono tabular-nums">
{w.p95_ms != null ? `${Math.round(w.p95_ms)}ms` : "—"} {w.p95_ms != null ? `${Math.round(w.p95_ms)}ms` : "—"}
</td> </span>
</tr> </div>
))} ))}
</tbody> </div>
</table> </div>
)}
</div>
{/* Reconnecting sessions */}
{(stats?.reconnects.length ?? 0) > 0 && (
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-gray-100 flex items-center gap-2">
<RotateCcw size={13} className="text-amber-400" strokeWidth={1.75} />
<span className="text-[13px] font-semibold text-gray-900">
Top reconnecting sessions
</span>
<div className="flex items-center gap-1 text-[11px] font-medium text-amber-600 bg-amber-50 border border-amber-200 rounded-full px-2 py-0.5 ml-1">
<AlertTriangle size={10} />
{stats.reconnects.length} sessions
</div>
</div>
{/* Header */}
<div className="grid grid-cols-[1fr_120px] gap-4 px-4 py-2.5 border-b border-gray-100 bg-gray-50">
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">
Session ID
</span>
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide text-right">
Reconnects
</span>
</div>
<div className="divide-y divide-gray-100">
{stats.reconnects.map((r) => (
<div
key={r.id}
className="grid grid-cols-[1fr_120px] gap-4 px-4 py-2.5 items-center"
>
<code className="text-[11px] font-mono text-gray-500 truncate">
{r.id}
</code>
<div className="flex items-center justify-end gap-1.5">
<span className="text-[12px] font-semibold text-amber-600 tabular-nums">
{r.reconnect_count}
</span>
<Layers size={11} className="text-amber-400" />
</div>
</div>
))}
</div>
</div> </div>
)} )}
</div> </div>
{/* Top reconnecting sessions */}
{(stats?.reconnects.length ?? 0) > 0 && (
<div className="border rounded-lg p-4">
<h2 className="font-medium text-sm mb-3">
Sessions by reconnect count
</h2>
<div className="space-y-1">
{stats.reconnects.map((r) => (
<div
key={r.id}
className="flex items-center justify-between text-xs"
>
<span className="font-mono text-muted-foreground truncate">
{r.id}
</span>
<span className="font-medium ml-4 shrink-0">
{r.reconnect_count} reconnects
</span>
</div>
))}
</div>
</div>
)}
</div> </div>
); );
} }