diff --git a/src/deliveries.rs b/src/deliveries.rs index 750180e..2a95a40 100644 --- a/src/deliveries.rs +++ b/src/deliveries.rs @@ -90,7 +90,7 @@ pub async fn get_webhook_deliveries( #[utoipa::path( get, path = "/api/v1/deliveries/{id}", - params(("id" = i64, Path, description = "Delivery ID")), + params(("id" = String, Path, description = "Delivery ID")), responses( (status = 200, description = "Delivery", body = Delivery), (status = 404, description = "Not found") @@ -99,7 +99,7 @@ pub async fn get_webhook_deliveries( )] pub async fn get_delivery( State(state): State, - Path(id): Path, + Path(id): Path, ) -> Result, StatusCode> { let delivery = sqlx::query_as::<_, Delivery>("SELECT * FROM deliveries WHERE id = ?") .bind(id) diff --git a/src/events.rs b/src/events.rs index 4c103fc..4e8372d 100644 --- a/src/events.rs +++ b/src/events.rs @@ -89,7 +89,7 @@ pub async fn get_webhook_events( #[utoipa::path( get, path = "/api/v1/events/{id}", - params(("id" = i64, Path, description = "Event ID")), + params(("id" = String, Path, description = "Event ID")), responses( (status = 200, description = "Event", body = Event), (status = 404, description = "Not found") @@ -98,7 +98,7 @@ pub async fn get_webhook_events( )] pub async fn get_event( State(state): State, - Path(id): Path, + Path(id): Path, ) -> Result, StatusCode> { let event = sqlx::query_as::<_, Event>("SELECT * FROM events WHERE id = ?") .bind(id) diff --git a/src/logs.rs b/src/logs.rs index a090717..94e8a35 100644 --- a/src/logs.rs +++ b/src/logs.rs @@ -8,7 +8,7 @@ use crate::app::AppState; #[derive(Debug, Serialize, Deserialize, sqlx::FromRow, ToSchema)] pub struct Log { - id: i64, + id: String, webhook_id: Option, level: String, message: String, diff --git a/src/settings.rs b/src/settings.rs index f402f68..7352bdf 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -27,7 +27,7 @@ pub struct UpsertSettings { #[utoipa::path( get, path = "/api/v1/event-types/{id}/settings", - params(("id" = i64, Path, description = "Event type ID")), + params(("id" = String, Path, description = "Event type ID")), responses( (status = 200, description = "Settings for event type", body = Settings), (status = 404, description = "No settings found for event type") @@ -36,10 +36,10 @@ pub struct UpsertSettings { )] pub async fn get_event_type_settings( State(state): State, - Path(event_type_id): Path, + Path(event_type_id): Path, ) -> Result, StatusCode> { let settings = sqlx::query_as::<_, Settings>("SELECT * FROM settings WHERE event_type_id = ?") - .bind(event_type_id) + .bind(&event_type_id) .fetch_one(&state.pool) .await .map_err(|_| StatusCode::NOT_FOUND)?; @@ -49,7 +49,7 @@ pub async fn get_event_type_settings( #[utoipa::path( put, path = "/api/v1/event-types/{id}/settings", - params(("id" = i64, Path, description = "Event type ID")), + params(("id" = String, Path, description = "Event type ID")), request_body = UpsertSettings, responses( (status = 200, description = "Upserted settings", body = Settings) @@ -58,7 +58,7 @@ pub async fn get_event_type_settings( )] pub async fn upsert_event_type_settings( State(state): State, - Path(event_type_id): Path, + Path(event_type_id): Path, Json(payload): Json, ) -> Result, StatusCode> { let setting_id = generate_id("ST"); diff --git a/web/app/providers.tsx b/web/app/providers.tsx index a56f3a0..0ae00ed 100644 --- a/web/app/providers.tsx +++ b/web/app/providers.tsx @@ -1,10 +1,15 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ThemeProvider } from "next-themes"; -import type { ReactNode } from "react"; +import { useState, type ReactNode } from "react"; export function Providers({ children }: { children: ReactNode }) { + const [queryClient] = useState(() => new QueryClient()); + return ( - - {children} - + + + {children} + + ); } diff --git a/web/app/routes.ts b/web/app/routes.ts index 8d7ddce..98ba235 100644 --- a/web/app/routes.ts +++ b/web/app/routes.ts @@ -10,8 +10,12 @@ export default [ index("routes/overview.tsx"), route("webhooks", "routes/webhooks.tsx"), route("webhooks/new", "routes/create-webhook.tsx"), + route("webhooks/:id/edit", "routes/edit-webhook.tsx"), + route("webhooks/:id", "routes/webhook-detail.tsx"), + route("webhooks/:webhookId/settings/:eventTypeId", "routes/event-settings.tsx"), route("deliveries", "routes/deliveries.tsx"), route("deliveries/:id", "routes/delivery-detail.tsx"), + route("events/:id", "routes/event-detail.tsx"), route("logs", "routes/logs.tsx"), route("settings", "routes/settings.tsx"), ]), diff --git a/web/app/routes/create-webhook.tsx b/web/app/routes/create-webhook.tsx index 05d8ed6..199fda1 100644 --- a/web/app/routes/create-webhook.tsx +++ b/web/app/routes/create-webhook.tsx @@ -1,4 +1,65 @@ +import { redirect } from "react-router"; +import { z } from "zod"; +import { createWebhook } from "@/lib/api"; import { CreateWebhookPage } from "@/components/pages/create-webhook"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; + +const createWebhookSchema = z.object({ + name: z.string().trim().min(1, "Name is required."), + url: z.string().trim().url("Enter a valid URL."), +}); + +export function HydrateFallback() { + return ( +
+
+ +
+ + +
+
+
+ + + + + + +
+ + +
+
+ + +
+
+
+
+ + +
+
+
+ ); +} + +export async function clientAction({ request }: { request: Request }) { + const formData = await request.formData(); + const result = createWebhookSchema.safeParse({ + name: formData.get("name"), + url: formData.get("url"), + }); + + if (!result.success) { + return { ok: false, error: result.error.issues[0]?.message }; + } + + await createWebhook(result.data); + return redirect("/webhooks"); +} export default function CreateWebhook() { return ; diff --git a/web/app/routes/deliveries.tsx b/web/app/routes/deliveries.tsx index bfeecaf..8151593 100644 --- a/web/app/routes/deliveries.tsx +++ b/web/app/routes/deliveries.tsx @@ -1,5 +1,48 @@ +import { getDeliveries } from "@/lib/api"; +import type { Delivery } from "@/lib/types"; import { DeliveriesPage } from "@/components/pages/deliveries"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Card } from "@/components/ui/card"; -export default function Deliveries() { - return ; +const DEFAULT_LIMIT = 10; + +export async function clientLoader({ request }: { request: Request }) { + const url = new URL(request.url); + const page = Number(url.searchParams.get("page")) || 0; + const limit = Number(url.searchParams.get("limit")) || DEFAULT_LIMIT; + const deliveries = await getDeliveries({ page, limit }); + return { deliveries, page, limit }; +} + +export function HydrateFallback() { + return ( +
+
+ + +
+ +
+ + {["del-1", "del-2", "del-3", "del-4"].map((id) => ( + + ))} +
+
+
+ ); +} + +export default function Deliveries({ + loaderData, +}: { + loaderData: { deliveries: Delivery[]; page: number; limit: number }; +}) { + return ( + + ); } diff --git a/web/app/routes/delivery-detail.tsx b/web/app/routes/delivery-detail.tsx index cdffc99..59d55c2 100644 --- a/web/app/routes/delivery-detail.tsx +++ b/web/app/routes/delivery-detail.tsx @@ -1,5 +1,73 @@ +import { getDelivery } from "@/lib/api"; +import type { Delivery } from "@/lib/types"; import { DeliveryDetailPage } from "@/components/pages/delivery-detail"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; -export default function DeliveryDetail() { - return ; +export async function clientLoader({ + params, +}: { params: { id: string } }) { + const delivery = await getDelivery(params.id); + return { delivery }; +} + +export function HydrateFallback() { + return ( +
+
+ +
+ + +
+
+
+ + + + + +
+ {["ev-name", "status", "duration", "timestamp"].map((id) => ( +
+ + +
+ ))} +
+
+
+ + + + + + + + + +
+ + + + +
+ + +
+
+
+
+
+
+ ); +} + +export default function DeliveryDetail({ + loaderData, +}: { + loaderData: { delivery: Delivery }; +}) { + return ; } diff --git a/web/app/routes/edit-webhook.tsx b/web/app/routes/edit-webhook.tsx new file mode 100644 index 0000000..7468232 --- /dev/null +++ b/web/app/routes/edit-webhook.tsx @@ -0,0 +1,91 @@ +import { redirect } from "react-router"; +import { z } from "zod"; +import { getWebhook, updateWebhook } from "@/lib/api"; +import type { Webhook } from "@/lib/types"; +import { EditWebhookPage } from "@/components/pages/edit-webhook"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; + +const editWebhookSchema = z.object({ + name: z.string().trim().min(1, "Name is required."), + url: z.string().trim().url("Enter a valid URL."), +}); + +function requireWebhookId(params: { id?: string }) { + if (!params.id) { + throw new Response("Webhook ID is required.", { status: 400 }); + } + + return params.id; +} + +export function HydrateFallback() { + return ( +
+
+ +
+ + +
+
+
+ + + + + + +
+ + +
+
+ + +
+
+
+
+ + +
+
+
+ ); +} + +export async function clientLoader({ params }: { params: { id?: string } }) { + const webhook = await getWebhook(requireWebhookId(params)); + return { webhook }; +} + +export async function clientAction({ + request, + params, +}: { + request: Request; + params: { id?: string }; +}) { + const webhookId = requireWebhookId(params); + const formData = await request.formData(); + const result = editWebhookSchema.safeParse({ + name: formData.get("name"), + url: formData.get("url"), + }); + + if (!result.success) { + return { ok: false, error: result.error.issues[0]?.message }; + } + + await updateWebhook(webhookId, result.data); + return redirect(`/webhooks/${webhookId}`); +} + +export default function EditWebhook({ + loaderData, +}: { + loaderData: { webhook: Webhook }; +}) { + return ; +} diff --git a/web/app/routes/event-detail.tsx b/web/app/routes/event-detail.tsx new file mode 100644 index 0000000..affc048 --- /dev/null +++ b/web/app/routes/event-detail.tsx @@ -0,0 +1,49 @@ +import { getEvent, getWebhookEvents } from "@/lib/api"; +import type { Event } from "@/lib/types"; +import { EventDetailPage } from "@/components/pages/event-detail"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; + +export async function clientLoader({ params }: { params: { id: string } }) { + const event = await getEvent(params.id); + return { event }; +} + +export function HydrateFallback() { + return ( +
+
+ +
+ + +
+
+
+ + + + + +
+ {[1, 2, 3, 4].map((i) => ( +
+ + +
+ ))} +
+
+
+
+
+ ); +} + +export default function EventDetail({ + loaderData, +}: { + loaderData: { event: Event }; +}) { + return ; +} diff --git a/web/app/routes/event-settings.tsx b/web/app/routes/event-settings.tsx new file mode 100644 index 0000000..43948c0 --- /dev/null +++ b/web/app/routes/event-settings.tsx @@ -0,0 +1,102 @@ +import { redirect } from "react-router"; +import { z } from "zod"; +import { getWebhook, getSettings, updateSettings } from "@/lib/api"; +import { + EventSettingsPage, + type EventSettingsValues, +} from "@/components/pages/event-settings"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; + +const settingsSchema = z.object({ + retry_attempts: z.coerce.number().int().min(0).max(10), + timeout_seconds: z.coerce.number().int().min(1).max(300), + enabled: z.boolean(), +}); + +function defaultSettings(eventTypeId: string): EventSettingsValues { + return { + event_type_id: eventTypeId, + retry_attempts: 3, + timeout_seconds: 30, + enabled: true, + persisted: false, + }; +} + +async function loadSettings(eventTypeId: string): Promise { + try { + const settings = await getSettings(eventTypeId); + return { + event_type_id: settings.event_type_id, + retry_attempts: settings.retry_attempts, + timeout_seconds: settings.timeout_seconds, + enabled: settings.enabled, + persisted: true, + }; + } catch { + return defaultSettings(eventTypeId); + } +} + +export async function clientLoader({ params }: { params: { webhookId: string; eventTypeId: string } }) { + const [webhook, settings] = await Promise.all([ + getWebhook(params.webhookId), + loadSettings(params.eventTypeId), + ]); + return { webhook, settings }; +} + +export async function clientAction({ request, params }: { request: Request; params: { webhookId: string; eventTypeId: string } }) { + const formData = await request.formData(); + const result = settingsSchema.safeParse({ + retry_attempts: formData.get("retry_attempts"), + timeout_seconds: formData.get("timeout_seconds"), + enabled: formData.getAll("enabled").includes("true"), + }); + + if (!result.success) { + return { ok: false, error: result.error.issues[0]?.message }; + } + + await updateSettings(params.eventTypeId, result.data); + return redirect(`/webhooks/${params.webhookId}`); +} + +export function HydrateFallback() { + return ( +
+
+ +
+ + +
+
+ + + + + +
+ + +
+
+ + +
+ +
+
+
+ ); +} + +export default function EventSettings({ + loaderData, +}: { + loaderData: { webhook: { id: string; name: string }; settings: EventSettingsValues }; +}) { + return ; +} diff --git a/web/app/routes/logs.tsx b/web/app/routes/logs.tsx index 11289f7..a941fcf 100644 --- a/web/app/routes/logs.tsx +++ b/web/app/routes/logs.tsx @@ -1,5 +1,51 @@ +import { getLogs } from "@/lib/api"; +import type { Log } from "@/lib/types"; import { LogsPage } from "@/components/pages/logs"; +import { Skeleton } from "@/components/ui/skeleton"; -export default function Logs() { - return ; +const DEFAULT_LIMIT = 10; + +export async function clientLoader({ request }: { request: Request }) { + const url = new URL(request.url); + const page = Number(url.searchParams.get("page")) || 0; + const limit = Number(url.searchParams.get("limit")) || DEFAULT_LIMIT; + const logs = await getLogs({ page, limit }); + return { logs, page, limit }; +} + +export function HydrateFallback() { + return ( +
+
+ + +
+ +
+ {["log-1", "log-2", "log-3", "log-4", "log-5"].map((id) => ( +
+ +
+ + +
+
+ ))} +
+
+ ); +} + +export default function Logs({ + loaderData, +}: { + loaderData: { logs: Log[]; page: number; limit: number }; +}) { + return ( + + ); } diff --git a/web/app/routes/overview.tsx b/web/app/routes/overview.tsx index 079d3c9..3318ea2 100644 --- a/web/app/routes/overview.tsx +++ b/web/app/routes/overview.tsx @@ -1,5 +1,103 @@ +import { getWebhooks, getDeliveries, getEvents } from "@/lib/api"; +import type { Webhook, Delivery, Event } from "@/lib/types"; import { OverviewPage } from "@/components/pages/overview"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; -export default function Overview() { - return ; +export async function clientLoader() { + const [webhooks, deliveries, events] = await Promise.all([ + getWebhooks({ limit: 200 }), + getDeliveries({ limit: 200 }), + getEvents({ limit: 10 }), + ]); + + return { webhooks, deliveries, events }; +} + +export function HydrateFallback() { + return ( +
+
+ + +
+ +
+ {["stat-1", "stat-2", "stat-3", "stat-4"].map((id) => ( + + + + + + + + + + ))} +
+ +
+ + + + + + + + {["healthy", "inactive", "failing"].map((id) => ( +
+ + +
+ ))} +
+
+ + + + + + {["p50", "p95", "p99"].map((id) => ( +
+
+ + +
+ +
+ ))} +
+
+
+ + + + + + + {["row-1", "row-2", "row-3", "row-4", "row-5"].map((id) => ( + + ))} + + +
+ ); +} + +export default function Overview({ + loaderData, +}: { + loaderData: { + webhooks: Webhook[]; + deliveries: Delivery[]; + events: Event[]; + }; +}) { + return ( + + ); } diff --git a/web/app/routes/settings.tsx b/web/app/routes/settings.tsx index f29ee43..8ae235a 100644 --- a/web/app/routes/settings.tsx +++ b/web/app/routes/settings.tsx @@ -1,5 +1,120 @@ +import { z } from "zod"; +import { getEventTypes, getSettings, updateSettings } from "@/lib/api"; +import type { Settings, EventType } from "@/lib/types"; import { SettingsPage } from "@/components/pages/settings"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; -export default function Settings() { - return ; +export interface SettingsFormValues { + retry_attempts: number; + timeout_seconds: number; + enabled: boolean; + persisted: boolean; +} + +const settingsFormSchema = z.object({ + eventTypeId: z.string().min(1, "Event type is required."), + retry_attempts: z.coerce.number().int().min(0).max(10), + timeout_seconds: z.coerce.number().int().min(1).max(300), + enabled: z.boolean(), +}); + +function defaultSettings(): SettingsFormValues { + return { + retry_attempts: 3, + timeout_seconds: 30, + enabled: true, + persisted: false, + }; +} + +function toSettingsFormValues(settings: Settings): SettingsFormValues { + return { + retry_attempts: settings.retry_attempts, + timeout_seconds: settings.timeout_seconds, + enabled: settings.enabled, + persisted: true, + }; +} + +export async function clientLoader() { + const eventTypes = await getEventTypes(); + const settingsByEventTypeId: Record = {}; + + await Promise.all( + eventTypes.map(async (eventType) => { + try { + const settings = await getSettings(eventType.id); + settingsByEventTypeId[eventType.id] = toSettingsFormValues(settings); + } catch { + settingsByEventTypeId[eventType.id] = defaultSettings(); + } + }), + ); + + return { eventTypes, settingsByEventTypeId }; +} + +export function HydrateFallback() { + return ( +
+
+ + +
+ + + + + + +
+
+ + +
+
+ + +
+
+
+ +
+
+
+
+ ); +} + +export async function clientAction({ request }: { request: Request }) { + const formData = await request.formData(); + const result = settingsFormSchema.safeParse({ + eventTypeId: formData.get("eventTypeId"), + retry_attempts: formData.get("retry_attempts"), + timeout_seconds: formData.get("timeout_seconds"), + enabled: formData.getAll("enabled").includes("true"), + }); + + if (!result.success) { + return { ok: false, error: result.error.issues[0]?.message }; + } + + const { eventTypeId, ...settings } = result.data; + const updated = await updateSettings(eventTypeId, settings); + return { settings: updated }; +} + +export default function SettingsRoute({ + loaderData, +}: { + loaderData: { + eventTypes: EventType[]; + settingsByEventTypeId: Record; + }; +}) { + return ; } diff --git a/web/app/routes/webhook-detail.tsx b/web/app/routes/webhook-detail.tsx new file mode 100644 index 0000000..aa0ce50 --- /dev/null +++ b/web/app/routes/webhook-detail.tsx @@ -0,0 +1,176 @@ +import { redirect } from "react-router"; +import { z } from "zod"; +import { + getWebhook, + getWebhookEventTypes, + getWebhookEvents, + getWebhookDeliveries, + updateWebhook, + deleteWebhook, + createEventType, + dispatch, + getSettings, +} from "@/lib/api"; +import type { + Webhook, + EventType, + Event, + Delivery, + Settings, +} from "@/lib/types"; +import { WebhookDetailPage } from "@/components/pages/webhook-detail"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; + +const webhookStatusSchema = z.enum(["active", "disabled", "paused"]); +const eventTypeNameSchema = z.string().trim().min(1, "Event name is required."); +const dispatchSchema = z.object({ + eventType: z.string().trim().min(1, "Event type is required."), + payload: z + .string() + .default("{}") + .transform((value, ctx) => { + try { + return JSON.parse(value || "{}"); + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Payload must be valid JSON.", + }); + return z.NEVER; + } + }), +}); + +export async function clientLoader({ params }: { params: { id: string } }) { + const [webhook, eventTypes, events, deliveries] = await Promise.all([ + getWebhook(params.id), + getWebhookEventTypes(params.id), + getWebhookEvents(params.id, { limit: 10 }), + getWebhookDeliveries(params.id, { limit: 10 }), + ]); + + // Fetch settings for each event type + const settingsMap: Record = {}; + await Promise.all( + eventTypes.map(async (et) => { + try { + const s = await getSettings(et.id); + settingsMap[et.id] = s; + } catch { + settingsMap[et.id] = { + id: "", + event_type_id: et.id, + retry_attempts: 3, + timeout_seconds: 30, + enabled: true, + }; + } + }) + ); + + return { webhook, eventTypes, events, deliveries, settings: settingsMap }; +} + +export async function clientAction({ + request, + params, +}: { + request: Request; + params: { id: string }; +}) { + const formData = await request.formData(); + const intent = formData.get("intent"); + + if (intent === "updateStatus") { + const status = webhookStatusSchema.parse(formData.get("status")); + await updateWebhook(params.id, { status }); + return { ok: true }; + } + + if (intent === "delete") { + await deleteWebhook(params.id); + return redirect("/webhooks"); + } + + if (intent === "createEventType") { + const name = eventTypeNameSchema.parse(formData.get("name")); + await createEventType({ webhook_id: params.id, name }); + return { ok: true }; + } + + if (intent === "dispatch") { + const result = dispatchSchema.safeParse({ + eventType: formData.get("eventType"), + payload: formData.get("payload"), + }); + + if (!result.success) { + return { ok: false, error: result.error.issues[0]?.message }; + } + + const webhook = await getWebhook(params.id); + const dispatched = await dispatch({ + webhook_name: webhook.name, + event_type: result.data.eventType, + payload: result.data.payload, + }); + return { + ok: true, + eventId: dispatched.event_id, + pollUntil: Date.now() + 10_000, + }; + } + + return { ok: false }; +} + +export function HydrateFallback() { + return ( +
+
+ +
+ + +
+
+
+ {[1, 2, 3].map((i) => ( + + + + + + + + + ))} +
+ + + + + + {[1, 2, 3].map((i) => ( + + ))} + + +
+ ); +} + +export default function WebhookDetail({ + loaderData, +}: { + loaderData: { + webhook: Webhook; + eventTypes: EventType[]; + events: Event[]; + deliveries: Delivery[]; + settings: Record; + }; +}) { + return ; +} diff --git a/web/app/routes/webhooks.tsx b/web/app/routes/webhooks.tsx index 4294eaf..cbe93d1 100644 --- a/web/app/routes/webhooks.tsx +++ b/web/app/routes/webhooks.tsx @@ -1,5 +1,76 @@ +import { z } from "zod"; +import { getWebhooks, deleteWebhook, updateWebhook } from "@/lib/api"; +import type { Webhook } from "@/lib/types"; import { WebhooksPage } from "@/components/pages/webhooks"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Card } from "@/components/ui/card"; -export default function Webhooks() { - return ; +const DEFAULT_LIMIT = 10; +const webhookStatusSchema = z.enum(["active", "disabled", "paused"]); +const webhookIdSchema = z.string().min(1, "Webhook ID is required."); + +export async function clientLoader({ request }: { request: Request }) { + const url = new URL(request.url); + const page = Number(url.searchParams.get("page")) || 0; + const limit = Number(url.searchParams.get("limit")) || DEFAULT_LIMIT; + const webhooks = await getWebhooks({ page, limit }); + return { webhooks, page, limit }; +} + +export async function clientAction({ request }: { request: Request }) { + const formData = await request.formData(); + const intent = formData.get("intent"); + + if (intent === "toggleStatus") { + const webhookId = webhookIdSchema.parse(formData.get("webhookId")); + const status = webhookStatusSchema.parse(formData.get("status")); + await updateWebhook(webhookId, { status }); + return { ok: true }; + } + + if (intent === "delete") { + const webhookId = webhookIdSchema.parse(formData.get("webhookId")); + await deleteWebhook(webhookId); + return { ok: true }; + } + + return { ok: false, error: "Unsupported webhook action." }; +} + +export function HydrateFallback() { + return ( +
+
+ + +
+
+ + +
+ + +
+ + {["wh-1", "wh-2", "wh-3"].map((id) => ( + + ))} +
+
+
+ ); +} + +export default function Webhooks({ + loaderData, +}: { + loaderData: { webhooks: Webhook[]; page: number; limit: number }; +}) { + return ( + + ); } diff --git a/web/components.json b/web/components.json index 4ee62ee..d1812d1 100644 --- a/web/components.json +++ b/web/components.json @@ -1,7 +1,7 @@ { "$schema": "https://ui.shadcn.com/schema.json", "style": "new-york", - "rsc": true, + "rsc": false, "tsx": true, "tailwind": { "config": "", diff --git a/web/components/deliveries-table.tsx b/web/components/deliveries-table.tsx index 835f6fd..2c90cd1 100644 --- a/web/components/deliveries-table.tsx +++ b/web/components/deliveries-table.tsx @@ -1,148 +1,97 @@ -import { Inbox } from "lucide-react"; import { useNavigate } from "react-router"; +import { Inbox } from "lucide-react"; import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, } from "@/components/ui/table"; - -const deliveries = [ - { - id: 1, - event: "user.created", - status: 200, - duration: "145ms", - timestamp: "2025-12-08 14:32:00", - success: true, - }, - { - id: 2, - event: "order.completed", - status: 200, - duration: "234ms", - timestamp: "2025-12-08 14:31:15", - success: true, - }, - { - id: 3, - event: "payment.failed", - status: 500, - duration: "156ms", - timestamp: "2025-12-08 14:28:42", - success: false, - }, - { - id: 4, - event: "user.updated", - status: 200, - duration: "89ms", - timestamp: "2025-12-08 14:25:00", - success: true, - }, - { - id: 5, - event: "invoice.generated", - status: 202, - duration: "512ms", - timestamp: "2025-12-08 14:22:30", - success: true, - }, -]; +import type { Delivery } from "@/lib/types"; interface DeliveriesTableProps { - onRowClick?: (delivery: any) => void; + deliveries: Delivery[]; } -export function DeliveriesTable({ onRowClick }: DeliveriesTableProps = {}) { - const navigate = useNavigate(); - if (deliveries.length === 0) { - return ( -
- -

No deliveries

-

- Deliveries will appear here once webhooks start firing. -

-
- ); - } +export function DeliveriesTable({ deliveries }: DeliveriesTableProps) { + const navigate = useNavigate(); + + if (deliveries.length === 0) { + return ( +
+ +

No deliveries

+

+ Deliveries will appear here once webhooks start firing. +

+
+ ); + } - return ( -
- - - - - Event - - - Status - - - Duration - - - Time - - - + return ( +
+
+ + + + Event + + + Status + + + Duration + + + Time + + + - - {deliveries.map((delivery) => { - const isError = delivery.status >= 400; + + {deliveries.map((delivery) => { + const isError = delivery.status_code >= 400 || !delivery.success; - return ( - { - if (onRowClick) { - onRowClick(delivery); - } else { - navigate(`/deliveries/${delivery.id}`); - } - }} - className="cursor-pointer border-b last:border-0 hover:bg-muted/40 transition-colors" - > - {/* Event */} - - - {delivery.event} - - + return ( + navigate(`/deliveries/${delivery.id}`)} + className="cursor-pointer border-b last:border-0 hover:bg-muted/40 transition-colors" + > + + + {delivery.event_id.slice(0, 12)}... + + - {/* HTTP Status */} - - - {delivery.status} - - + + + {delivery.status_code} + + - {/* Duration */} - - {delivery.duration} - + + {delivery.duration_ms}ms + - {/* Time */} - - {delivery.timestamp} - - - ); - })} - -
-
- ); + + {delivery.timestamp} + + + ); + })} + + + + ); } diff --git a/web/components/logs-list.tsx b/web/components/logs-list.tsx index 97ea87d..64eccc2 100644 --- a/web/components/logs-list.tsx +++ b/web/components/logs-list.tsx @@ -8,47 +8,19 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import type { Log } from "@/lib/types"; -const logs = [ - { - id: 1, - level: "info", - timestamp: "14:35:22", - message: "Webhook delivered successfully to user.created endpoint", - }, - { - id: 2, - level: "info", - timestamp: "14:32:15", - message: "New webhook endpoint registered: Payment Notifications", - }, - { - id: 3, - level: "warn", - timestamp: "14:28:42", - message: "Webhook delivery timeout - retrying in 60s", - }, - { - id: 4, - level: "error", - timestamp: "14:25:30", - message: "Failed to deliver webhook after 3 retry attempts", - }, - { - id: 5, - level: "info", - timestamp: "14:20:10", - message: "Webhook endpoint disabled due to repeated failures", - }, -]; - -const levelDot = { +const levelDot: Record = { info: "bg-muted-foreground/70", warn: "bg-yellow-500/80", error: "bg-red-500/80", }; -export function LogsList() { +interface LogsListProps { + logs: Log[]; +} + +export function LogsList({ logs }: LogsListProps) { if (logs.length === 0) { return ( @@ -68,7 +40,7 @@ export function LogsList() { return ( - + @@ -77,20 +49,17 @@ export function LogsList() { key={log.id} className="hover:bg-muted/30 transition-colors" > - {/* Dot */} - {/* Time */} {log.timestamp} - {/* Message */} - + {log.message} diff --git a/web/components/pages/create-webhook.tsx b/web/components/pages/create-webhook.tsx index a5d7190..fa66d72 100644 --- a/web/components/pages/create-webhook.tsx +++ b/web/components/pages/create-webhook.tsx @@ -1,11 +1,8 @@ -import { useState } from "react"; -import { useNavigate } from "react-router"; +import { Form, useActionData, useNavigate, useNavigation } from "react-router"; import { ArrowLeft } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Checkbox } from "@/components/ui/checkbox"; -import { ScrollArea } from "@/components/ui/scroll-area"; import { Card, CardContent, @@ -14,28 +11,14 @@ import { CardTitle, } from "@/components/ui/card"; -const events = [ - "user.created", - "user.updated", - "user.deleted", - "order.created", - "order.completed", - "payment.processed", -]; - export function CreateWebhookPage() { const navigate = useNavigate(); - const [name, setName] = useState(""); - const [url, setUrl] = useState(""); - const [selectedEvents, setSelectedEvents] = useState([]); - - const handleSubmit = () => { - // TODO: wire to API - navigate("/webhooks"); - }; + const navigation = useNavigation(); + const actionData = useActionData() as { error?: string } | undefined; + const isSubmitting = navigation.state === "submitting"; return ( -
+
-
+
Endpoint Details @@ -71,8 +54,8 @@ export function CreateWebhookPage() { setName(e.target.value)} + name="name" + required placeholder="My Webhook" />
@@ -81,58 +64,31 @@ export function CreateWebhookPage() { setUrl(e.target.value)} + required placeholder="https://api.example.com/webhooks" />
- - - Events - - Choose which events trigger this webhook. - - - - - -
- {events.map((event) => ( - - ))} -
-
-
-
-
- - +
-
+ ); } diff --git a/web/components/pages/deliveries.tsx b/web/components/pages/deliveries.tsx index 6de170d..01f113e 100644 --- a/web/components/pages/deliveries.tsx +++ b/web/components/pages/deliveries.tsx @@ -1,9 +1,39 @@ +import { useNavigate } from "react-router"; +import type { Delivery } from "@/lib/types"; import { DeliveriesTable } from "../deliveries-table"; import { Card } from "@/components/ui/card"; +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination"; + +interface DeliveriesPageProps { + deliveries: Delivery[]; + page: number; + limit: number; +} + +export function DeliveriesPage({ deliveries, page, limit }: DeliveriesPageProps) { + const navigate = useNavigate(); + const hasMore = deliveries.length === limit; + + const handlePrev = () => { + if (page > 0) { + navigate(`?page=${page - 1}&limit=${limit}`); + } + }; + + const handleNext = () => { + if (hasMore) { + navigate(`?page=${page + 1}&limit=${limit}`); + } + }; -export function DeliveriesPage() { return ( -
+

Deliveries @@ -14,8 +44,34 @@ export function DeliveriesPage() {

- + + + + + { + e.preventDefault(); + handlePrev(); + }} + aria-disabled={page === 0} + className={page === 0 ? "pointer-events-none opacity-50" : undefined} + /> + + + { + e.preventDefault(); + handleNext(); + }} + aria-disabled={!hasMore} + className={!hasMore ? "pointer-events-none opacity-50" : undefined} + /> + + +
); } diff --git a/web/components/pages/delivery-detail.tsx b/web/components/pages/delivery-detail.tsx index 1f4dc32..daf139e 100644 --- a/web/components/pages/delivery-detail.tsx +++ b/web/components/pages/delivery-detail.tsx @@ -1,191 +1,184 @@ -import { useNavigate, useParams } from "react-router"; -import { ArrowLeft } from "lucide-react"; -import { Badge } from "@/components/ui/badge"; +import { useNavigate } from "react-router"; +import { ArrowLeft, Clock, Hash, Send, Server } from "lucide-react"; import { Button } from "@/components/ui/button"; import { - Card, - CardContent, - CardHeader, - CardTitle, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, } from "@/components/ui/card"; -import { Separator } from "@/components/ui/separator"; - -// TODO: replace with API fetch by ID -const deliveries: Record = { - "1": { - id: 1, - event: "user.created", - status: 200, - duration: "145ms", - timestamp: "2025-12-08 14:32:00", - success: true, - }, - "2": { - id: 2, - event: "order.completed", - status: 200, - duration: "234ms", - timestamp: "2025-12-08 14:31:15", - success: true, - }, - "3": { - id: 3, - event: "payment.failed", - status: 500, - duration: "156ms", - timestamp: "2025-12-08 14:28:42", - success: false, - }, - "4": { - id: 4, - event: "user.updated", - status: 200, - duration: "89ms", - timestamp: "2025-12-08 14:25:00", - success: true, - }, - "5": { - id: 5, - event: "invoice.generated", - status: 202, - duration: "512ms", - timestamp: "2025-12-08 14:22:30", - success: true, - }, -}; +import type { Delivery } from "@/lib/types"; + +interface DeliveryDetailPageProps { + delivery: Delivery; +} -export function DeliveryDetailPage() { - const navigate = useNavigate(); - const { id } = useParams(); - const delivery = id ? deliveries[id] : null; - - if (!delivery) { - return ( -
-
- -

- Delivery Not Found -

-
-

- The delivery you're looking for doesn't exist. -

-
- ); - } - - return ( -
-
- -
-

- Delivery Details -

-

{delivery.event}

-
-
- -
- - - Event Information - - -
-
-

Event Name

-

{delivery.event}

-
-
-

Status Code

- {delivery.status} -
-
-

Duration

-

- {delivery.duration} -

-
-
-

Timestamp

-

- {delivery.timestamp} -

-
-
-
-
- - - - Request Payload - - -
-							{`{
-  "id": "evt_123456",
-  "event": "${delivery.event}",
-  "timestamp": "${delivery.timestamp}",
-  "data": {
-    "userId": "user_789",
-    "email": "user@example.com"
+const getDeliveryStatusStyles = (success: boolean) => {
+  if (success) {
+    return {
+      dot: "bg-emerald-500",
+      text: "text-emerald-600",
+      label: "Success",
+    };
   }
+
+  return {
+    dot: "bg-red-500",
+    text: "text-red-600",
+    label: "Failed",
+  };
+};
+
+export function DeliveryDetailPage({ delivery }: DeliveryDetailPageProps) {
+  const navigate = useNavigate();
+  const status = getDeliveryStatusStyles(delivery.success);
+
+  return (
+    
+
+ + +
+
+

+ Delivery Details +

+ +
+ + {status.label} +
+
+ +

+ {delivery.id} +

+
+
+ +
+ + + + Delivery Information + + + Runtime metadata for this delivery attempt + + + + +
+
+
+ + Status +
+ +
+ + + HTTP {delivery.status_code ?? "Failed"} + +
+
+ +
+
+ + Duration +
+ +

+ {delivery.duration_ms}ms +

+
+ +
+
+ + Result +
+ +

+ {status.label} +

+
+
+ +
+
+

Timestamp

+

+ {delivery.timestamp} +

+
+ +
+

Event ID

+

+ {delivery.event_id} +

+
+
+
+
+ + + + + Request Payload + + + Payload metadata sent with this delivery + + + + +
+
+
+ + JSON +
+
+ +
+                {`{
+  "delivery_id": "${delivery.id}",
+  "event_id": "${delivery.event_id}",
+  "timestamp": "${delivery.timestamp}"
 }`}
-						
- - - - - - Response Payload - - -
-							{`{
-  "received": true,
-  "id": "evt_123456"
-}`}
-						
-
-
- - - -
-

- Retries -

- - - Success -
-

- Attempt 1 -

-

- 2025-12-08 14:32:00 -

-
-
-
-
-
-
- ); +
+
+ + + +
+

Delivery Status

+

+ This delivery{" "} + {delivery.success ? "completed successfully" : "failed"} with{" "} + + HTTP {delivery.status_code ?? "N/A"} + {" "} + in{" "} + + {delivery.duration_ms}ms + + . +

+
+
+
+ ); } diff --git a/web/components/pages/edit-webhook.tsx b/web/components/pages/edit-webhook.tsx new file mode 100644 index 0000000..abe5dc6 --- /dev/null +++ b/web/components/pages/edit-webhook.tsx @@ -0,0 +1,115 @@ +import { Form, useActionData, useNavigate, useNavigation } from "react-router"; +import { ArrowLeft } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import type { Webhook } from "@/lib/types"; + +interface EditWebhookPageProps { + webhook: Webhook; +} + +export function EditWebhookPage({ webhook }: EditWebhookPageProps) { + const navigate = useNavigate(); + const navigation = useNavigation(); + const actionData = useActionData() as { error?: string } | undefined; + const isSubmitting = navigation.state === "submitting"; + + return ( +
+
+
+ + +
+

+ Edit Webhook +

+

+ Update the endpoint name and destination URL. +

+
+
+ +
+ + + + Endpoint Details + + + Changes will apply to future delivery attempts for this webhook. + + + + + {actionData?.error && ( +
+ {actionData.error} +
+ )} + +
+ + +

+ A short label to help you identify this webhook. +

+
+ +
+ + +

+ The HTTPS endpoint that will receive webhook events. +

+
+
+ + + + + +
+ +
+
+ ); +} diff --git a/web/components/pages/event-detail.tsx b/web/components/pages/event-detail.tsx new file mode 100644 index 0000000..fcf8825 --- /dev/null +++ b/web/components/pages/event-detail.tsx @@ -0,0 +1,183 @@ +import { useNavigate } from "react-router"; +import { ArrowLeft, Clock, Hash, RotateCw, Webhook } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import type { Event } from "@/lib/types"; + +interface EventDetailPageProps { + event: Event; +} + +const getStatusStyles = (status: string) => { + switch (status) { + case "success": + return { + dot: "bg-emerald-500", + text: "text-emerald-600", + }; + case "fail": + case "failed": + return { + dot: "bg-red-500", + text: "text-red-600", + }; + default: + return { + dot: "bg-zinc-400", + text: "text-muted-foreground", + }; + } +}; + +export function EventDetailPage({ event }: EventDetailPageProps) { + const navigate = useNavigate(); + const status = getStatusStyles(event.status); + + return ( +
+ {/* Header */} +
+ + +
+
+

+ Event Details +

+ +
+ + {event.status} +
+
+ +

+ {event.id} +

+
+
+ +
+ {/* Event Information */} + + + + Event Information + + + Runtime metadata for this webhook event + + + + +
+
+
+ + Attempts +
+

+ {event.attempts} +

+
+ +
+
+ + Duration +
+

+ {event.duration_ms}ms +

+
+ +
+
+ + Status +
+
+ + {event.status} +
+
+
+ +
+

Timestamp

+

+ {event.timestamp} +

+
+
+
+ + {/* References */} + + + References + + Linked resources associated with this event + + + + +
+
+
+ + Webhook ID +
+
+ +
+

+ {event.webhook_id} +

+
+
+ +
+
+
+ + Event Type ID +
+
+ +
+

+ {event.event_type_id} +

+
+
+
+
+ + {/* Note */} +
+

Payload unavailable

+

+ The payload sent with this event is not stored in the database. To + inspect payload data, check the delivery attempt or use test + dispatch. +

+
+
+
+ ); +} diff --git a/web/components/pages/event-settings.tsx b/web/components/pages/event-settings.tsx new file mode 100644 index 0000000..ff16d37 --- /dev/null +++ b/web/components/pages/event-settings.tsx @@ -0,0 +1,168 @@ +import { Form, useActionData, useNavigate, useNavigation } from "react-router"; +import { ArrowLeft } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import type { Settings as SettingsType } from "@/lib/types"; + +export type EventSettingsValues = Pick< + SettingsType, + "event_type_id" | "retry_attempts" | "timeout_seconds" | "enabled" +> & { + persisted: boolean; +}; + +interface EventSettingsPageProps { + webhook: { id: string; name: string }; + settings: EventSettingsValues; +} + +export function EventSettingsPage({ + webhook, + settings, +}: EventSettingsPageProps) { + const navigate = useNavigate(); + const navigation = useNavigation(); + const actionData = useActionData() as { error?: string } | undefined; + const isSubmitting = navigation.state === "submitting"; + + return ( +
+
+
+ + +
+

+ Event Settings +

+

+ Configure delivery behavior for{" "} + + {webhook.name} + +

+

+ Event type:{" "} + + {settings.event_type_id} + +

+ {!settings.persisted && ( +

+ Using default delivery settings until you save. +

+ )} +
+
+ + + + + Delivery Configuration + + + Control retries, timeouts, and whether this event type can send + deliveries. + + + + +
+
+
+ + +

+ Number of times to retry delivery after a failed request. +

+
+ +
+ + +

+ Maximum seconds to wait before marking a delivery as failed. +

+
+
+ +
+
+
+ +

+ Allow this event type to trigger webhook deliveries. +

+
+ +
+ + +
+
+
+ +
+ {actionData?.error && ( +

+ {actionData.error} +

+ )} + + +
+ +
+
+
+
+ ); +} diff --git a/web/components/pages/logs.tsx b/web/components/pages/logs.tsx index 9c15642..b3f3a41 100644 --- a/web/components/pages/logs.tsx +++ b/web/components/pages/logs.tsx @@ -1,20 +1,81 @@ +import { useMemo, useState } from "react"; +import { useNavigate } from "react-router"; +import type { Log } from "@/lib/types"; +import { LogsFilter } from "../logs-filter"; +import { LogsList } from "../logs-list"; +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination"; -import { useState } from "react" -import { LogsFilter } from "../logs-filter" -import { LogsList } from "../logs-list" +interface LogsPageProps { + logs: Log[]; + page: number; + limit: number; +} + +export function LogsPage({ logs, page, limit }: LogsPageProps) { + const [logLevel, setLogLevel] = useState("all"); + const navigate = useNavigate(); + const hasMore = logs.length === limit; + + const filtered = useMemo(() => { + if (logLevel === "all") return logs; + return logs.filter((log) => log.level === logLevel); + }, [logs, logLevel]); + + const handlePrev = () => { + if (page > 0) { + navigate(`?page=${page - 1}&limit=${limit}`); + } + }; -export function LogsPage() { - const [logLevel, setLogLevel] = useState("all") + const handleNext = () => { + if (hasMore) { + navigate(`?page=${page + 1}&limit=${limit}`); + } + }; - return ( -
-
-

Logs

-

View system events and error logs

-
+ return ( +
+
+

Logs

+

+ View system events and error logs +

+
- - -
- ) + + + + + + { + e.preventDefault(); + handlePrev(); + }} + aria-disabled={page === 0} + className={page === 0 ? "pointer-events-none opacity-50" : undefined} + /> + + + { + e.preventDefault(); + handleNext(); + }} + aria-disabled={!hasMore} + className={!hasMore ? "pointer-events-none opacity-50" : undefined} + /> + + + +
+ ); } diff --git a/web/components/pages/overview.tsx b/web/components/pages/overview.tsx index 351f766..0251f40 100644 --- a/web/components/pages/overview.tsx +++ b/web/components/pages/overview.tsx @@ -1,161 +1,244 @@ import { - CheckCircle, - TrendingUp, - Zap, - Radio, - Activity, - Gauge, + CheckCircle, + TrendingUp, + Zap, + Radio, + Activity, + Gauge, } from "lucide-react"; +import { format } from "@lukeed/ms"; import { StatCard } from "../stat-card"; import { RecentEventsTable } from "../recent-events-table"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import type { Webhook, Delivery, Event } from "@/lib/types"; -export function OverviewPage() { - return ( -
-
-

- Overview -

-

- Monitor your webhook system health at a glance -

-
-
- - - - -
-
- - -
- - Endpoint Status -
-
- 22 -
-
- -
-
-
-
-
+interface OverviewPageProps { + webhooks: Webhook[]; + deliveries: Delivery[]; + events: Event[]; +} + +function computeStats(webhooks: Webhook[], deliveries: Delivery[]) { + const activeCount = webhooks.filter((w) => w.status === "active").length; + const totalEndpoints = webhooks.length; + + const successCount = deliveries.filter((d) => d.success).length; + const successRate = + deliveries.length > 0 + ? ((successCount / deliveries.length) * 100).toFixed(1) + : "0.0"; + + const durations = deliveries.map((d) => d.duration_ms); + const avgLatency = + durations.length > 0 + ? Math.round(durations.reduce((a, b) => a + b, 0) / durations.length) + : 0; + + return { + activeCount, + totalEndpoints, + successRate, + successCount, + avgLatency, + totalDeliveries: deliveries.length, + }; +} + +export function OverviewPage({ + webhooks, + deliveries, + events, +}: OverviewPageProps) { + const stats = computeStats(webhooks, deliveries); + + // Endpoint status breakdown + const healthyCount = webhooks.filter((w) => w.status === "active").length; + const inactiveCount = webhooks.filter((w) => w.status === "inactive").length; + const failingCount = webhooks.filter((w) => w.status === "failed").length; + const total = webhooks.length || 1; + const healthyPct = Math.round((healthyCount / total) * 100); + const inactivePct = Math.round((inactiveCount / total) * 100); + const failingPct = Math.round((failingCount / total) * 100); + + // Percentile latencies + const sortedDurations = deliveries + .map((d) => d.duration_ms) + .sort((a, b) => a - b); + const percentile = (arr: number[], p: number) => + arr.length > 0 ? arr[Math.ceil((p / 100) * arr.length) - 1] : 0; + const p50 = percentile(sortedDurations, 50); + const p95 = percentile(sortedDurations, 95); + const p99 = percentile(sortedDurations, 99); + const maxLatency = sortedDurations[sortedDurations.length - 1] || 1; + + return ( +
+
+

+ Overview +

+

+ Monitor your webhook system health at a glance +

+
+
+ + + + +
+
+ + +
+ + Endpoint Status +
+
+ {webhooks.length} +
+
+ +
+
+
+
+
-
-
-
- - Healthy -
- 20 -
+
+
+
+ + Healthy +
+ {healthyCount} +
-
-
- - Degraded -
- 2 -
+
+
+ + Inactive +
+ + {inactiveCount} + +
-
-
- - Failing -
- 0 -
-
- - - - -
- Performance -
-
- -
-
- P50{" "} - 145ms -
-
-
-
-
+
+
+ + Failing +
+ {failingCount} +
+
+
+
+ + +
+ Performance +
+
+ +
+
+ P50{" "} + + {format(p50)} + +
+
+
+
+
-
-
- P95 - 512ms -
-
-
-
-
+
+
+ P95 + + {format(p95)} + +
+
+
+
+
-
-
- P99 - 1.2s -
-
-
-
-
- - -
- - - Recent Events - - - - - -
- ); +
+
+ P99 + + {format(p99)} + +
+
+
+
+
+ + +
+ + + Recent Events + + + + + +
+ ); } diff --git a/web/components/pages/settings.tsx b/web/components/pages/settings.tsx index 605298b..7b88ee1 100644 --- a/web/components/pages/settings.tsx +++ b/web/components/pages/settings.tsx @@ -1,8 +1,9 @@ -import { Eye, EyeOff, Copy, RotateCw } from "lucide-react"; import { useState } from "react"; +import { useFetcher } from "react-router"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; import { Card, CardContent, @@ -10,136 +11,42 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { EventType } from "@/lib/types"; +import type { SettingsFormValues } from "@/app/routes/settings"; + +interface SettingsPageProps { + eventTypes: EventType[]; + settingsByEventTypeId: Record; +} -export function SettingsPage() { - const [showSecret, setShowSecret] = useState(false); - const [showApiKey, setShowApiKey] = useState(false); - const [copied, setCopied] = useState(""); - - const handleCopy = (text: string, id: string) => { - navigator.clipboard.writeText(text); - setCopied(id); - setTimeout(() => setCopied(""), 1500); - }; +export function SettingsPage({ + eventTypes, + settingsByEventTypeId, +}: SettingsPageProps) { + const [selectedEventType, setSelectedEventType] = useState(eventTypes[0]?.id || ""); + const fetcher = useFetcher<{ + ok?: boolean; + error?: string; + settings?: SettingsFormValues; + }>(); + const isSaving = fetcher.state !== "idle"; + const settings = settingsByEventTypeId[selectedEventType]; return ( -
+

Settings

- Manage your webhook configuration and API access + Manage webhook delivery configuration

-
- - - - Signing Secret - - - Used to verify webhook payloads - - - -
- - -
- - - - - -
- - {copied === "secret" && ( -

Copied

- )} -
- - -
-
- - - - API Key - - Used for authenticating API requests - - - -
- - -
- - - - - -
- - {copied === "apikey" && ( -

Copied

- )} -
-
-
-
@@ -147,40 +54,109 @@ export function SettingsPage() { Delivery Configuration - Control webhook retry behavior + Control webhook retry behavior per event type - -
+ + {eventTypes.length === 0 ? ( +

+ No event types configured. Create a webhook with event types first. +

+ ) : ( + +
-
+
+
+
+ +

+ Allow this event type to trigger webhook deliveries. +

+
+ +
+ + +
+
+
+
+ + +
-
- - +
+ + +
-
-
- -
+
+ {fetcher.data?.error && ( +

+ {fetcher.data.error} +

+ )} + +
+
+ )}
diff --git a/web/components/pages/webhook-detail.tsx b/web/components/pages/webhook-detail.tsx new file mode 100644 index 0000000..aa4c77e --- /dev/null +++ b/web/components/pages/webhook-detail.tsx @@ -0,0 +1,710 @@ +import { useState, type FormEvent } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + useNavigate, + Form, + Link, + useFetcher, +} from "react-router"; +import { + ArrowLeft, + Plus, + Play, + Edit, + Trash2, + Radio, + Activity, + Send, + Copy, + Settings as SettingsIcon, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { + Webhook, + EventType, + Event, + Delivery, + Settings, +} from "@/lib/types"; +import { getWebhookDeliveries, getWebhookEvents } from "@/lib/api"; + +interface WebhookDetailPageProps { + webhook: Webhook; + eventTypes: EventType[]; + events: Event[]; + deliveries: Delivery[]; + settings: Record; +} + +const RECENT_ACTIVITY_LIMIT = 10; +const RECENT_ACTIVITY_POLL_MS = 500; +const PENDING_EVENT_STATUSES = new Set(["sending", "submitting"]); + +const hasPendingEvents = (events: Event[]) => + events.some((event) => PENDING_EVENT_STATUSES.has(event.status)); + +const getWebhookStatusStyles = (status: string) => { + switch (status) { + case "active": + return { + dot: "bg-emerald-500", + text: "text-emerald-600", + }; + case "disabled": + return { + dot: "bg-red-500", + text: "text-red-600", + }; + default: + return { + dot: "bg-zinc-400", + text: "text-muted-foreground", + }; + } +}; + +const getEventStatusStyles = (status: string) => { + switch (status) { + case "success": + return { + dot: "bg-emerald-500", + text: "text-emerald-600", + }; + case "fail": + case "failed": + return { + dot: "bg-red-500", + text: "text-red-600", + }; + default: + return { + dot: "bg-zinc-400", + text: "text-muted-foreground", + }; + } +}; + +export function WebhookDetailPage({ + webhook, + eventTypes, + events, + deliveries, + settings, +}: WebhookDetailPageProps) { + const navigate = useNavigate(); + const createEventTypeFetcher = useFetcher<{ ok: boolean }>(); + const dispatchFetcher = useFetcher<{ + ok: boolean; + eventId?: string; + pollUntil?: number; + error?: string; + }>(); + const [isDispatchOpen, setIsDispatchOpen] = useState(false); + const [isAddEventTypeOpen, setIsAddEventTypeOpen] = useState(false); + const [dispatchPayload, setDispatchPayload] = useState("{}"); + const [dispatchPayloadError, setDispatchPayloadError] = useState(""); + const [copied, setCopied] = useState(false); + + const webhookStatus = getWebhookStatusStyles(webhook.status); + const dispatchedEventId = dispatchFetcher.data?.ok + ? dispatchFetcher.data.eventId + : undefined; + const dispatchedEventPollUntil = dispatchFetcher.data?.pollUntil ?? 0; + + const eventsQuery = useQuery({ + queryKey: ["webhook-events", webhook.id], + queryFn: () => + getWebhookEvents(webhook.id, { limit: RECENT_ACTIVITY_LIMIT }), + initialData: events, + refetchInterval: (query) => { + const queryEvents = query.state.data ?? []; + const dispatchedEvent = dispatchedEventId + ? queryEvents.find((event) => event.id === dispatchedEventId) + : undefined; + const dispatchedEventMissing = Boolean( + dispatchedEventId && + Date.now() < dispatchedEventPollUntil && + !dispatchedEvent + ); + const dispatchedEventPending = Boolean( + dispatchedEvent && PENDING_EVENT_STATUSES.has(dispatchedEvent.status) + ); + + return dispatchedEventMissing || + dispatchedEventPending || + hasPendingEvents(queryEvents) + ? RECENT_ACTIVITY_POLL_MS + : false; + }, + }); + const recentEvents = eventsQuery.data ?? events; + const deliveriesQuery = useQuery({ + queryKey: ["webhook-deliveries", webhook.id], + queryFn: () => + getWebhookDeliveries(webhook.id, { limit: RECENT_ACTIVITY_LIMIT }), + initialData: deliveries, + refetchInterval: (query) => { + const queryDeliveries = query.state.data ?? []; + const dispatchedDeliveryMissing = Boolean( + dispatchedEventId && + Date.now() < dispatchedEventPollUntil && + !queryDeliveries.some( + (delivery) => delivery.event_id === dispatchedEventId + ) + ); + + return hasPendingEvents(recentEvents) || dispatchedDeliveryMissing + ? RECENT_ACTIVITY_POLL_MS + : false; + }, + }); + const recentDeliveries = deliveriesQuery.data ?? deliveries; + + const handleCopyUrl = () => { + navigator.clipboard.writeText(webhook.url); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; + + const handleDispatchSubmit = (event: FormEvent) => { + try { + JSON.parse(dispatchPayload || "{}"); + } catch { + event.preventDefault(); + setDispatchPayloadError("Payload must be valid JSON."); + return; + } + + setDispatchPayloadError(""); + setIsDispatchOpen(false); + }; + + return ( +
+
+
+ + +
+
+

+ {webhook.name} +

+ +
+ + {webhook.status} +
+
+ +
+

{webhook.url}

+ + + + {copied && ( + Copied + )} +
+
+
+ +
+ + +
+ + + +
+
+ +
+ + +
+ + Event Types +
+
+ +

+ {eventTypes.length} +

+
+
+ + + +
+ + Total Events +
+
+ +

+ {recentEvents.length} +

+
+
+ + + +
+ + Total Deliveries +
+
+ +

+ {recentDeliveries.length} +

+
+
+
+ +
+ + +
+ + Event Types + + + Events this webhook can receive + +
+ + + + + + + + + Add Event Type + + Create a new event type for this webhook. + + + + setIsAddEventTypeOpen(false)} + > + + +
+ + +
+ +
+ +
+
+
+
+
+ + + {eventTypes.length > 0 ? ( +
+ {eventTypes.map((eventType) => ( +
+
+ + + {eventType.name} + +
+ +
+ + {eventType.id.slice(0, 8)} + + +
+
+ ))} +
+ ) : ( +
+

No event types

+

+ Add one to start receiving webhook events. +

+
+ )} +
+
+ + + + + Test Dispatch + + + Send a test payload to this webhook endpoint + + + + +
+
+
+ +
+ +
+

Dispatch a test event

+

+ Choose an event type and send a JSON payload to verify the + endpoint behavior. +

+
+
+
+ + + + + + + + + Dispatch Test Event + + Send a test event to {webhook.name}. + + + + + + +
+ + +
+ +
+ +