feat: test server for webhook
This commit is contained in:
parent
f8b10288be
commit
f4632e77d5
8 changed files with 1136 additions and 461 deletions
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
111
scripts/webhook-test-server.ts
Normal file
111
scripts/webhook-test-server.ts
Normal 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`);
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue