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/"
params={{ appId }}
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> </Link>
<h1 className="text-xl font-semibold">Delivery log</h1> <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>
<div className="px-8 py-8">
{/* Loading */}
{(isPending as boolean) && ( {(isPending as boolean) && (
<p className="text-sm text-muted-foreground">Loading…</p> <div className="space-y-2.5">
<SkeletonRow />
<SkeletonRow />
<SkeletonRow />
</div>
)} )}
{/* Empty state */}
{!(isPending as boolean) && deliveries.length === 0 && ( {!(isPending as boolean) && deliveries.length === 0 && (
<p className="text-sm text-muted-foreground">No deliveries yet.</p> <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>
)} )}
<div className="space-y-2"> {/* Delivery rows */}
{deliveries.map((d) => ( <div className="space-y-2.5">
{deliveries.map((d) => {
const cfg = STATUS_CONFIG[d.status];
return (
<div <div
key={d.id} key={d.id}
className="border rounded-lg p-3 flex items-start gap-3" className="group rounded-xl border border-gray-100 bg-white overflow-hidden hover:shadow-sm transition-shadow"
> >
<div className="mt-0.5 shrink-0">{STATUS_ICON[d.status]}</div> <div className="flex">
<div className="flex-1 min-w-0"> <div className={cn("w-0.5 shrink-0", cfg.bar)} />
<div className="flex items-center gap-2 flex-wrap"> <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 <span
className={cn( className={cn(
"text-xs px-2 py-0.5 rounded border font-medium", "inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded-full border",
STATUS_BADGE[d.status], cfg.pill,
)} )}
> >
{d.status} {cfg.icon}
{cfg.label}
</span> </span>
<Badge variant="secondary" className="text-xs"> <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} {d.eventType}
</Badge> </span>
{d.httpStatus && ( {d.httpStatus && (
<span className="text-xs text-muted-foreground"> <span className="font-mono text-[11px] text-gray-400">
HTTP {d.httpStatus} HTTP {d.httpStatus}
</span> </span>
)} )}
<span className="text-xs text-muted-foreground"> <span className="text-[11px] text-gray-300">
·
</span>
<span className="text-[11px] text-gray-400">
attempt {d.attempt} attempt {d.attempt}
</span> </span>
</div> </div>
<div className="flex items-center gap-4 mt-1 text-xs text-muted-foreground">
<span>{new Date(d.createdAt).toLocaleString()}</span> {/* 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 && ( {d.deliveredAt && (
<span> <span title={new Date(d.deliveredAt).toLocaleString()}>
delivered {new Date(d.deliveredAt).toLocaleString()} delivered {timeAgo(d.deliveredAt)}
</span> </span>
)} )}
{d.nextAttemptAt && d.status === "pending" && ( {d.nextAttemptAt && d.status === "pending" && (
<span> <span className="text-amber-500">
retry at {new Date(d.nextAttemptAt).toLocaleString()} retry in {timeUntil(d.nextAttemptAt)}
</span> </span>
)} )}
</div> </div>
)}
</div>
</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,77 +232,147 @@ 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>
)}
{!(isPending as boolean) && webhooks.length === 0 && (
<p className="text-sm text-muted-foreground">
No webhook endpoints yet.
</p>
)}
<div className="space-y-3"> <div className="space-y-3">
{webhooks.map((wh) => ( <SkeletonCard />
<SkeletonCard />
</div>
)}
{/* Empty state */}
{!(isPending as boolean) && webhooks.length === 0 && (
<div className="flex flex-col items-center justify-center py-24 text-center">
<div className="mb-5 flex items-center justify-center w-16 h-16 rounded-2xl bg-gray-100 text-gray-300">
<Webhook size={32} strokeWidth={1.25} />
</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">
Add a webhook endpoint to start receiving real-time event notifications via signed HTTP POST.
</p>
<Button
size="sm"
onClick={() => { setNewOpen(true); setCreatedSecret(null); }}
className="h-8 px-4 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white gap-1.5"
>
<Plus size={13} strokeWidth={2.5} /> Add endpoint
</Button>
</div>
)}
{/* Endpoint cards */}
<div className="space-y-3">
{webhooks.map((wh) => {
const { proto, rest } = splitUrl(wh.url);
const healthy = wh.enabled && !wh.unhealthySince;
const result = testResults[wh.id];
return (
<div <div
key={wh.id} key={wh.id}
className="border rounded-lg p-4 flex items-start justify-between gap-4" className={cn(
"group rounded-xl border bg-white overflow-hidden transition-shadow hover:shadow-sm",
healthy ? "border-gray-100" : "border-red-100",
)}
> >
{/* Left accent bar */}
<div className="flex">
<div
className={cn(
"w-0.5 shrink-0 rounded-l-xl",
healthy ? "bg-emerald-400" : "bg-red-400",
)}
/>
<div className="flex-1 p-5">
{/* Top row */}
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1"> {/* Status + URL */}
{wh.enabled ? ( <div className="flex items-center gap-2 mb-2.5">
<CheckCircle2 className="h-4 w-4 text-emerald-600 shrink-0" /> <span className="relative flex h-2 w-2 shrink-0">
{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" />
</>
) : ( ) : (
<XCircle className="h-4 w-4 text-red-500 shrink-0" /> <span className="relative inline-flex rounded-full h-2 w-2 bg-red-500" />
)}
<span className="font-mono text-sm truncate">{wh.url}</span>
{wh.unhealthySince && (
<Badge variant="destructive" className="shrink-0">
Unhealthy
</Badge>
)} )}
</span>
<span className="font-mono text-[13px] text-gray-800 truncate">
<span className="text-gray-400">{proto}</span>
{rest}
</span>
</div> </div>
<div className="flex flex-wrap gap-1 mt-1">
{(wh.events.length === 0 ? ["all events"] : wh.events).map( {/* Events */}
(ev) => ( <div className="flex flex-wrap gap-1 mb-2">
<Badge key={ev} variant="secondary" className="text-xs"> {(wh.events.length === 0
{ev} ? [{ label: "all events", all: true }]
</Badge> : 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> </div>
{/* Meta row */}
<div className="flex items-center gap-3 text-[11px] text-gray-400">
<span>Added {formatDate(wh.createdAt)}</span>
{wh.consecutiveFailures > 0 && ( {wh.consecutiveFailures > 0 && (
<p className="text-xs text-red-500 mt-1"> <span className="flex items-center gap-1 text-red-500">
{wh.consecutiveFailures} consecutive failure(s) <AlertCircle size={10} />
</p> {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>
<div className="flex items-center gap-2 shrink-0"> </div>
{/* Actions */}
<div className="flex items-center gap-1.5 shrink-0 pt-0.5">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
disabled={testingId === wh.id} disabled={testingId === wh.id}
onClick={() => testWebhook(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"
> >
<TestTube className="h-3 w-3 mr-1" /> <Zap size={11} className={testingId === wh.id ? "animate-pulse" : ""} />
{testingId === wh.id ? "Testing…" : "Test"} {testingId === wh.id ? "Testing…" : "Test"}
</Button> </Button>
<Link <Link
to="/apps/$appId/webhooks/$webhookId/deliveries" to="/apps/$appId/webhooks/$webhookId/deliveries"
params={{ appId, webhookId: wh.id }} params={{ appId, webhookId: wh.id }}
> >
<Button variant="outline" size="sm"> <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 Log
</Button> </Button>
</Link> </Link>
@ -238,41 +380,47 @@ function WebhooksPage() {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => setDeleteId(wh.id)} onClick={() => setDeleteId(wh.id)}
className="h-8 w-8 p-0 text-gray-300 hover:text-red-500 hover:bg-red-50"
> >
<Trash2 className="h-4 w-4 text-red-500" /> <Trash2 size={13} />
</Button> </Button>
</div> </div>
</div> </div>
))}
</div>
{testResult && ( {/* Inline test result */}
{result && (
<div <div
className={cn( className={cn(
"mt-4 p-3 rounded-lg text-sm border", "mt-3 flex items-center gap-2 rounded-lg px-3 py-2 text-[12px] border",
testResult.ok result.ok
? "border-emerald-200 bg-emerald-50 text-emerald-800" ? "bg-emerald-50 border-emerald-100 text-emerald-700"
: "border-red-200 bg-red-50 text-red-800", : "bg-red-50 border-red-100 text-red-700",
)} )}
> >
{testResult.ok ? ( {result.ok ? (
<>Test delivered — HTTP {testResult.httpStatus}</> <CheckCircle2 size={13} className="shrink-0" />
) : ( ) : (
<> <XCircle size={13} className="shrink-0" />
Test failed —{" "}
{testResult.error ?? `HTTP ${testResult.httpStatus}`}
</>
)} )}
<span>
{result.ok
? `Delivered — HTTP ${result.httpStatus}`
: `Failed — ${result.error ?? `HTTP ${result.httpStatus}`}`}
</span>
</div> </div>
)} )}
</div>
</div>
</div>
);
})}
</div>
</div>
{/* Create dialog */} {/* Create dialog */}
<Dialog <Dialog
open={newOpen} open={newOpen}
onOpenChange={(o) => { onOpenChange={(o) => { setNewOpen(o); if (!o) setCreatedSecret(null); }}
setNewOpen(o);
if (!o) setCreatedSecret(null);
}}
> >
<DialogContent className="max-w-lg"> <DialogContent className="max-w-lg">
<DialogHeader> <DialogHeader>
@ -283,53 +431,57 @@ function WebhooksPage() {
</DialogHeader> </DialogHeader>
{createdSecret ? ( {createdSecret ? (
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm text-emerald-700 font-medium"> <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">
Endpoint created. Copy your signing secret — it won't be shown <CheckCircle2 size={15} className="mt-0.5 shrink-0" />
again. <span>Endpoint created. Copy your signing secret — it won't be shown again.</span>
</p> </div>
<code className="block bg-muted rounded p-3 text-xs break-all select-all"> <code className="block bg-muted rounded-lg p-3 text-xs break-all select-all font-mono border">
{createdSecret} {createdSecret}
</code> </code>
<Button <Button
onClick={() => { onClick={() => { setNewOpen(false); setCreatedSecret(null); }}
setNewOpen(false); className="bg-zinc-900 hover:bg-zinc-800"
setCreatedSecret(null);
}}
> >
Done Done
</Button> </Button>
</div> </div>
) : ( ) : (
<form onSubmit={createWebhook} className="space-y-4"> <form onSubmit={createWebhook} className="space-y-4">
<div className="space-y-1"> <div className="space-y-1.5">
<Label>URL</Label> <Label>URL</Label>
<Input <Input
placeholder="https://example.com/webhook" placeholder="https://example.com/webhook"
value={newUrl} value={newUrl}
onChange={(e) => setNewUrl(e.target.value)} onChange={(e) => setNewUrl(e.target.value)}
className="font-mono text-sm"
required required
/> />
{newUrl.startsWith("http://") && {newUrl.startsWith("http://") && !newUrl.startsWith("https://") && (
!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">
<p className="text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded px-2 py-1 mt-1"> <AlertCircle size={12} className="mt-0.5 shrink-0" />
Warning: HTTP endpoints receive webhook payloads <span>HTTP endpoints receive webhook payloads unencrypted. Use HTTPS in production.</span>
unencrypted. Use HTTPS in production. </div>
</p> )}
{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>
<div className="space-y-1"> <div className="space-y-1.5">
<Label>Events (empty = all)</Label> <Label>Events <span className="text-gray-400 font-normal">(empty = all)</span></Label>
<div className="flex flex-wrap gap-2 mt-1"> <div className="flex flex-wrap gap-1.5 mt-1">
{ALL_EVENTS.map((ev) => ( {ALL_EVENTS.map((ev) => (
<button <button
key={ev} key={ev}
type="button" type="button"
onClick={() => toggleEvent(ev)} onClick={() => toggleEvent(ev)}
className={cn( className={cn(
"text-xs px-2 py-1 rounded border transition-colors", "font-mono text-[11px] px-2 py-1 rounded-md border transition-colors",
newEvents.includes(ev) newEvents.includes(ev)
? "bg-primary text-primary-foreground border-primary" ? "bg-zinc-900 text-white border-zinc-900"
: "border-border hover:bg-muted", : "border-gray-200 text-gray-600 hover:bg-gray-50",
)} )}
> >
{ev} {ev}
@ -338,10 +490,17 @@ function WebhooksPage() {
</div> </div>
</div> </div>
{createError && ( {createError && (
<p className="text-sm text-red-500">{createError}</p> <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}> <Button
{creating ? "Creating…" : "Create"} type="submit"
disabled={creating}
className="bg-zinc-900 hover:bg-zinc-800 w-full"
>
{creating ? "Creating…" : "Create endpoint"}
</Button> </Button>
</form> </form>
)} )}
@ -351,9 +510,7 @@ function WebhooksPage() {
{/* Delete confirm */} {/* Delete confirm */}
<Dialog <Dialog
open={!!deleteId} open={!!deleteId}
onOpenChange={(o) => { onOpenChange={(o) => { if (!o) setDeleteId(null); }}
if (!o) setDeleteId(null);
}}
> >
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
@ -377,6 +534,5 @@ function WebhooksPage() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </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">
{label}
</span> </span>
<div className="flex-1 h-4 bg-muted rounded overflow-hidden">
<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>
<p className="text-2xl font-bold text-gray-900 leading-none">
{depth}
</p>
<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> </div>
<span className="w-8 text-muted-foreground">{row.count}</span> <span className="text-[11px] font-semibold text-blue-500 tabular-nums w-6 text-right shrink-0">
<Badge variant="outline" className="text-xs py-0"> {d.in}
{row.direction === "in" ? "↓" : "↑"} </span>
</Badge> </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>
{/* Content */}
<div className="px-8 py-6 max-w-5xl animate-fade-up space-y-4">
{(isPending as boolean) && ( {(isPending as boolean) && (
<p className="text-sm text-muted-foreground mb-4">Loading…</p> <div className="flex items-center gap-2 text-[13px] text-gray-400">
<RefreshCw size={13} className="animate-spin" />
Loading stats…
</div>
)} )}
{/* Summary stats */} {/* KPI cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<StatCard label="Messages received (24h)" value={totalIn24h} /> <KpiCard
<StatCard label="Messages sent (24h)" value={totalOut24h} /> label="Received (24h)"
<StatCard label="Connected sessions" value={connectedSessions} /> value={totalIn24h}
<StatCard icon={ArrowDown}
label="Webhook success rate (24h)" 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}%` : "—"} value={webhookSuccessRate !== null ? `${webhookSuccessRate}%` : "—"}
sub={webhookTotal > 0 ? `${webhookTotal} total` : undefined} 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>
{/* Queue depths */} {/* Queue depths */}
<div className="grid grid-cols-2 gap-3 mb-6"> <div className="grid grid-cols-2 gap-3">
<StatCard <QueueCard label="Send queue" depth={stats?.queues.send ?? 0} />
label="Send queue depth" <QueueCard label="Webhook queue" depth={stats?.queues.webhook ?? 0} />
value={stats?.queues.send ?? "—"}
sub="pending jobs"
/>
<StatCard
label="Webhook queue depth"
value={stats?.queues.webhook ?? "—"}
sub="pending jobs"
/>
</div> </div>
{/* Messages chart + Session breakdown side by side */}
<div className="grid grid-cols-[1fr_280px] gap-3">
{/* Messages per hour */} {/* Messages per hour */}
<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 justify-between">
<MessageSquare className="h-4 w-4" /> <div className="flex items-center gap-2">
<h2 className="font-medium text-sm">Messages per hour (last 24h)</h2> <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>
{hourlyRows.length === 0 ? ( <div className="flex items-center gap-3 text-[11px] text-gray-400">
<p className="text-xs text-muted-foreground">No data</p> <span className="flex items-center gap-1">
) : ( <span className="inline-block w-2 h-2 rounded-full bg-blue-400" />
<div className="space-y-1">
{hourlyRows.map((row, i) => (
<MessageBar key={i} row={row} max={maxHourlyCount} />
))}
</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 Inbound
</span> </span>
<span> <span className="flex items-center gap-1">
<span className="inline-block w-2 h-2 rounded-full bg-emerald-400 mr-1" /> <span className="inline-block w-2 h-2 rounded-full bg-emerald-400" />
Outbound Outbound
</span> </span>
</div> </div>
</div> </div>
<div className="px-4 py-4">
<MessageChart rows={hourlyRows} />
</div>
</div>
{/* Session status */} {/* 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">
<Server className="h-4 w-4" /> <Server size={13} className="text-gray-400" strokeWidth={1.75} />
<h2 className="font-medium text-sm">Session status breakdown</h2> <span className="text-[13px] font-semibold text-gray-900">
Session status
</span>
</div> </div>
<div className="px-4 py-4">
{!stats?.sessions.length ? ( {!stats?.sessions.length ? (
<p className="text-xs text-muted-foreground">No sessions</p> <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="flex flex-wrap gap-2"> <div className="space-y-2">
{stats.sessions.map((s) => ( {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 <div
key={s.status}
className={cn( className={cn(
"text-xs px-3 py-1.5 rounded border font-medium", "w-1.5 h-1.5 rounded-full flex-shrink-0",
SESSION_STATUS_STYLE[s.status] || "border-border", style.dot,
)} )}
> />
{s.status}: {s.count} <span className="text-[12px] text-gray-600">
{style.label}
</span>
</div> </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>
</div>
{/* Webhook delivery stats */} {/* Webhook deliveries */}
<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" /> <Webhook 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">
Webhook deliveries
</span>
<span className="text-[11px] font-medium text-gray-400 bg-gray-100 rounded-full px-2 py-0.5 ml-1">
last 24h
</span>
</div> </div>
{!stats?.webhooks.length ? ( {!stats?.webhooks.length ? (
<p className="text-xs text-muted-foreground">No deliveries</p> <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 className="overflow-x-auto"> <div>
<table className="w-full text-xs"> {/* Table header */}
<thead> <div className="grid grid-cols-[1fr_100px_120px_120px] gap-4 px-4 py-2.5 border-b border-gray-100 bg-gray-50">
<tr className="text-muted-foreground"> <span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">
<th className="text-left pb-2">Status</th> Status
<th className="text-right pb-2">Count</th> </span>
<th className="text-right pb-2">p50 latency</th> <span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide text-right">
<th className="text-right pb-2">p95 latency</th> Count
</tr> </span>
</thead> <span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide text-right">
<tbody> 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>
)} )}
</div> </div>
{/* Top reconnecting sessions */} {/* Reconnecting sessions */}
{(stats?.reconnects.length ?? 0) > 0 && ( {(stats?.reconnects.length ?? 0) > 0 && (
<div className="border rounded-lg p-4"> <div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
<h2 className="font-medium text-sm mb-3"> <div className="px-4 py-3 border-b border-gray-100 flex items-center gap-2">
Sessions by reconnect count <RotateCcw size={13} className="text-amber-400" strokeWidth={1.75} />
</h2> <span className="text-[13px] font-semibold text-gray-900">
<div className="space-y-1"> 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) => ( {stats.reconnects.map((r) => (
<div <div
key={r.id} key={r.id}
className="flex items-center justify-between text-xs" className="grid grid-cols-[1fr_120px] gap-4 px-4 py-2.5 items-center"
> >
<span className="font-mono text-muted-foreground truncate"> <code className="text-[11px] font-mono text-gray-500 truncate">
{r.id} {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> </span>
<span className="font-medium ml-4 shrink-0"> <Layers size={11} className="text-amber-400" />
{r.reconnect_count} reconnects </div>
</span>
</div> </div>
))} ))}
</div> </div>
</div> </div>
)} )}
</div> </div>
</div>
); );
} }