diff --git a/dashboard/src/mocks.ts b/dashboard/src/mocks.ts new file mode 100644 index 0000000..9d33188 --- /dev/null +++ b/dashboard/src/mocks.ts @@ -0,0 +1,443 @@ +// Fake API responses for building the dashboard before the API exists. +// +// Shapes come from docs/api.md. Values are shaped like the real thing: UUIDv7 ids, +// ISO 8601 timestamps in UTC, example.com URLs and an obviously fake signing secret. +// +// The timelines add up. Each wait starts when the previous try ended, following the +// retry schedule in docs/CONTEXT.md: 5 s, 1 min, 5 min, 30 min, 2 h, 6 h, 12 h. + +import type { + ApiError, + Delivery, + DeliveryDetail, + Endpoint, + EndpointCreated, + Me, + Page, +} from './types'; + +// GET /v1/me + +export const me: Me = { + owner: { id: '0199c4f8-7e3a-7c21-9f4e-2b1d8a6c0f55', name: 'Acme AB' }, + key: { prefix: 'relay_live_8f2k' }, +}; + +// GET /v1/endpoints +// +// `status` is derived from each endpoint's latest delivery, so it matches the +// deliveries below: Slack's latest went through, n8n's is pending after a failed +// try, and Legacy billing has no deliveries at all. + +const slack = { + id: '0199c4f8-8a01-7d33-b2c7-5e9f1a4d7b20', + name: 'Slack alerts', + url: 'https://example.com/hooks/slack', +}; + +const n8n = { + id: '0199c4f9-1c44-7a08-8e51-7f2a9b3c6d14', + name: 'n8n order flow', + url: 'https://example.com/webhook/orders', +}; + +const billing = { + id: '0199c4f9-5d77-7e19-a3b6-9c1e4f8a2d03', + name: 'Legacy billing', + url: 'https://example.com/hooks/billing', +}; + +export const endpoints: { data: Endpoint[] } = { + data: [ + { + ...slack, + event_types: ['payment.completed', 'payment.failed', 'invoice.paid'], + status: 'active', + created_at: '2026-10-20T09:12:04.000Z', + }, + { + ...n8n, + event_types: ['*'], + status: 'failing', + created_at: '2026-10-21T14:38:51.000Z', + }, + { + ...billing, + event_types: ['invoice.paid'], + status: 'active', // no deliveries yet, and the rule's fallback is active + created_at: '2026-10-19T11:02:17.000Z', + }, + ], +}; + +// POST /v1/endpoints — the one answer that carries the signing secret. + +// Soft-deleted: gone from GET /v1/endpoints, but its old deliveries keep showing +// its name and URL, so the detail screen still has something to render. +const retiredCrm = { + id: '0199c4fa-9e21-7b45-8d72-3a6c0e5f1b88', + name: 'Old CRM', + url: 'https://example.com/hooks/crm', +}; + +export const endpointCreated: EndpointCreated = { + id: '0199c51f-2b90-7c55-9d18-4a7e3f1b8c62', + name: 'Ops webhook', + url: 'https://example.com/hooks/ops', + event_types: ['*'], + status: 'active', + created_at: '2026-10-25T15:44:02.000Z', + signing_secret: 'whsec_MOCK_not_a_real_secret', +}; + +// GET /v1/deliveries + +/** + * Mid-retry: three tries used, the fourth is due in five minutes. + * This is the row the detail screen polls every 2 seconds. + */ +const pendingDelivery: Delivery = { + id: '0199c5b2-3a41-7d66-b107-2e8f4a1c9d35', + status: 'pending', + attempts: 3, + next_attempt_at: '2026-10-25T15:34:15.350Z', + last_status_code: 503, + last_error: null, + delivered_at: null, + created_at: '2026-10-25T15:28:00.000Z', + event: { id: '0199c5b2-2f18-7c09-9e73-5a1b8d3f6e21', type: 'payment.completed' }, + endpoint: { id: n8n.id, name: n8n.name }, +}; + +/** Went through on the first try. The common case. */ +const deliveredDelivery: Delivery = { + id: '0199c5a7-6e23-7a90-8c14-3f7b2d9e5a68', + status: 'delivered', + attempts: 1, + next_attempt_at: null, + last_status_code: 200, + last_error: null, + delivered_at: '2026-10-25T15:10:00.095Z', + created_at: '2026-10-25T15:10:00.000Z', + event: { id: '0199c5a7-6d11-7b82-a05f-8e2c1a4d7f39', type: 'invoice.paid' }, + endpoint: { id: slack.id, name: slack.name }, +}; + +/** Timed out, then went through five seconds later. `attempts > 1` can still end well. */ +const recoveredDelivery: Delivery = { + id: '0199c59c-4b17-7f28-9a36-1d8e5c2b7a44', + status: 'delivered', + attempts: 2, + next_attempt_at: null, + last_status_code: 200, + last_error: null, + delivered_at: '2026-10-25T14:45:15.120Z', + created_at: '2026-10-25T14:45:00.000Z', + event: { id: '0199c59c-3a05-7e14-b821-6f4a9d1c3e57', type: 'payment.completed' }, + endpoint: { id: n8n.id, name: n8n.name }, +}; + +/** + * Gave up after eight tries: the exhausted-budget example. Not the only row that + * can be replayed — the contract allows replaying a pending delivery to retry it + * now, and answers 409 `in_flight` only while a worker is sending it. + */ +const deadDelivery: Delivery = { + id: '0199c51a-1f88-7b44-9c02-6d3e8f1a2b77', + status: 'dead', + attempts: 8, + next_attempt_at: null, + last_status_code: 500, + last_error: null, + delivered_at: null, + created_at: '2026-10-24T18:00:00.000Z', + event: { id: '0199c51a-1e02-7f31-8a45-1b9d2c7e4f08', type: 'payment.failed' }, + endpoint: { id: slack.id, name: slack.name }, +}; + +/** + * Died because its endpoint was deleted while it was still waiting, not because a + * try failed. `last_error` carries a reason no attempt produced, which is why + * `DeliveryError` is wider than `AttemptError`. Note `attempt_log` is short: the + * delivery was dropped between tries, so there is no attempt row explaining it. + */ +const endpointDeletedDelivery: Delivery = { + id: '0199c5c0-7a33-7e12-b640-8d2f5a1c3e97', + status: 'dead', + attempts: 1, + next_attempt_at: null, + last_status_code: null, + last_error: 'endpoint_deleted', + delivered_at: null, + created_at: '2026-10-25T13:20:00.000Z', + event: { id: '0199c5c0-6b22-7d01-a53f-7c1e4b9d2f86', type: 'invoice.paid' }, + endpoint: { id: retiredCrm.id, name: retiredCrm.name }, +}; + +/** Newest first, by `created_at`. */ +export const deliveries: Page = { + data: [ + pendingDelivery, + deliveredDelivery, + recoveredDelivery, + endpointDeletedDelivery, + deadDelivery, + ], + next_cursor: null, +}; + +// GET /v1/deliveries/:id + +/** + * The dead delivery with its payload and all eight tries. + * + * Every gap follows the schedule, measured from the end of the previous try: + * 5 s, 1 min, 5 min, 30 min, 2 h, 6 h, 12 h. Tries that got no HTTP answer carry + * an `error` and no `status_code`; tries the receiver answered carry the opposite. + */ +export const deliveryDetail: DeliveryDetail = { + ...deadDelivery, + event: { + id: deadDelivery.event.id, + type: 'payment.failed', + payload: { + order_id: 'A-1042', + customer_id: 'cus_8Hq2Lm', + amount: 249.0, + currency: 'SEK', + reason: 'card_declined', + }, + created_at: '2026-10-24T18:00:00.000Z', + }, + endpoint: slack, + attempt_log: [ + { + id: '0199c51a-2001-7a11-8b03-4c7d9e2f1a60', + number: 1, + started_at: '2026-10-24T18:00:00.000Z', + duration_ms: 10000, + status_code: null, + error: 'timeout', + response_excerpt: null, + }, + { + id: '0199c51a-2002-7b22-9c14-5d8e0f3a2b71', + number: 2, + started_at: '2026-10-24T18:00:15.000Z', + duration_ms: 180, + status_code: 500, + error: null, + response_excerpt: '{"ok":false,"error":"internal"}', + }, + { + id: '0199c51a-2003-7c33-a025-6e9f1a4b3c82', + number: 3, + started_at: '2026-10-24T18:01:15.180Z', + duration_ms: 165, + status_code: 500, + error: null, + response_excerpt: '{"ok":false,"error":"internal"}', + }, + { + id: '0199c51a-2004-7d44-b136-7f0a2b5c4d93', + number: 4, + started_at: '2026-10-24T18:06:15.345Z', + duration_ms: 10000, + status_code: null, + error: 'timeout', + response_excerpt: null, + }, + { + id: '0199c51a-2005-7e55-8247-801b3c6d5ea4', + number: 5, + started_at: '2026-10-24T18:36:25.345Z', + duration_ms: 142, + status_code: 502, + error: null, + response_excerpt: '502 Bad Gateway', + }, + { + id: '0199c51a-2006-7f66-9358-912c4d7e6fb5', + number: 6, + started_at: '2026-10-24T20:36:25.487Z', + duration_ms: 1, + status_code: null, + error: 'connect_failed', + response_excerpt: null, + }, + { + id: '0199c51a-2007-7a77-a469-a23d5e8f70c6', + number: 7, + started_at: '2026-10-25T02:36:25.488Z', + duration_ms: 210, + status_code: 500, + error: null, + response_excerpt: '{"ok":false,"error":"internal"}', + }, + { + id: '0199c51a-2008-7b88-b57a-b34e6f9a81d7', + number: 8, + started_at: '2026-10-25T14:36:25.698Z', + duration_ms: 155, + status_code: 500, + error: null, + response_excerpt: '{"ok":false,"error":"internal"}', + }, + ], +}; + +/** + * The pending delivery, three tries in. This is the one the detail screen reloads + * every 2 seconds, so it is the fixture for the live-retry part of the demo. + * + * Try 3 ends at 15:29:15.350; the fourth wait is 5 min, which is where + * `next_attempt_at` comes from. + */ +export const pendingDeliveryDetail: DeliveryDetail = { + ...pendingDelivery, + event: { + id: pendingDelivery.event.id, + type: 'payment.completed', + payload: { + order_id: 'A-1119', + customer_id: 'cus_4Rt7Yp', + amount: 1290.0, + currency: 'SEK', + }, + created_at: '2026-10-25T15:28:00.000Z', + }, + endpoint: n8n, + attempt_log: [ + { + id: '0199c5b2-3001-7c11-8a02-2b4d6e8f0a13', + number: 1, + started_at: '2026-10-25T15:28:00.000Z', + duration_ms: 10000, + status_code: null, + error: 'timeout', + response_excerpt: null, + }, + { + id: '0199c5b2-3002-7d22-9b13-3c5e7f9a1b24', + number: 2, + started_at: '2026-10-25T15:28:15.000Z', + duration_ms: 200, + status_code: 503, + error: null, + response_excerpt: '{"error":"service unavailable"}', + }, + { + id: '0199c5b2-3003-7e33-ac24-4d6f8a0b2c35', + number: 3, + started_at: '2026-10-25T15:29:15.200Z', + duration_ms: 150, + status_code: 503, + error: null, + response_excerpt: '{"error":"service unavailable"}', + }, + ], +}; + +/** + * The deleted-endpoint delivery, opened. The endpoint is not in `endpoints.data` + * any more, but it is still nested here, which is what lets the screen name where + * the event was going. One attempt, then the endpoint went away. + */ +export const endpointDeletedDetail: DeliveryDetail = { + ...endpointDeletedDelivery, + event: { + id: endpointDeletedDelivery.event.id, + type: 'invoice.paid', + payload: { invoice_id: 'INV-2026-0871', customer_id: 'cus_5Nm3Qx', amount: 1150.0, currency: 'SEK' }, + created_at: '2026-10-25T13:20:00.000Z', + }, + endpoint: retiredCrm, + attempt_log: [ + { + id: '0199c5c0-8001-7f21-9a04-9e3f6b2d4c18', + number: 1, + started_at: '2026-10-25T13:20:00.000Z', + duration_ms: 10000, + status_code: null, + error: 'timeout', + response_excerpt: null, + }, + ], +}; + +// Errors +// +// Every failing request answers with this shape. The dashboard renders these +// in different screens: unauthorized (Connect), already_delivered and in_flight +// and endpoint_deleted (Replay), endpoint_limit_reached (Add Endpoint), and +// validation_failed with details (form validation). + +export const errorUnauthorized: ApiError = { + error: { + code: 'unauthorized', + message: 'Unknown or revoked API key', + request_id: '0199c5b5-7a12-7f40-9c33-1e2d4a6b8c05', + }, +}; + +export const errorAlreadyDelivered: ApiError = { + error: { + code: 'already_delivered', + message: 'Delivery has already been delivered', + request_id: '0199c5b5-8b23-7a51-ad44-2f3e5b7c9d16', + }, +}; + +export const errorInFlight: ApiError = { + error: { + code: 'in_flight', + message: 'A worker is sending this delivery right now', + request_id: '0199c5b5-9c34-7b62-be55-3a4f6c8d0e27', + }, +}; + +export const errorEndpointDeleted: ApiError = { + error: { + code: 'endpoint_deleted', + message: 'The endpoint for this delivery has been deleted', + request_id: '0199c5b5-ad45-7c73-8f66-4b5a7d9e1f38', + }, +}; + +// Owners get 20 endpoints. The 21st is refused, which the Add endpoint form has +// to say out loud rather than just failing silently. +export const errorEndpointLimitReached: ApiError = { + error: { + code: 'endpoint_limit_reached', + message: 'This account already has the maximum of 20 endpoints', + request_id: '0199c5b5-be56-7d84-9077-5c6b8e0f2a49', + }, +}; + +// A 422 carries `details`, one entry per field that failed, so the form can put +// each message next to the input it belongs to instead of in one lump. +export const errorValidationFailed: ApiError = { + error: { + code: 'validation_failed', + message: 'The request body failed validation', + request_id: '0199c5b5-cf67-7e95-a188-6d7c9f1a3b50', + details: [ + { path: 'url', message: 'must be an https URL that is not private or internal' }, + { path: 'name', message: 'must be between 1 and 100 characters' }, + ], + }, +}; + +// POST /v1/deliveries/:id/replay +// +// Replay starts a new cycle: `attempts` goes back to 0, so the delivery gets the +// full retry budget again. `attempt_log` keeps its eight rows, and the next try +// becomes number 9. + +export const replayResponse: Delivery = { + ...deadDelivery, + status: 'pending', + attempts: 0, + next_attempt_at: '2026-10-25T15:45:00.000Z', + last_error: null, +}; diff --git a/dashboard/src/types.ts b/dashboard/src/types.ts new file mode 100644 index 0000000..b337292 --- /dev/null +++ b/dashboard/src/types.ts @@ -0,0 +1,102 @@ +// Response types for Relay API v1. +// +// Copied from docs/api.md, which is the single source of truth for what the API +// accepts and returns. Changing a shape here without changing it there makes the +// dashboard lie about the API: change docs/api.md first, in its own PR. + +export type DeliveryStatus = 'pending' | 'delivered' | 'dead'; +export type EndpointStatus = 'active' | 'failing'; +export type AttemptError = + | 'timeout' + | 'connect_failed' + | 'blocked_address' + | 'redirect' + | 'tls'; +// A delivery can also end for a reason no single try caused: its endpoint was +// deleted while it was still pending. +export type DeliveryError = AttemptError | 'endpoint_deleted'; + +export interface Owner { + id: string; + name: string; +} + +// GET /v1/me +export interface Me { + owner: Owner; + key: { prefix: string }; // e.g. "relay_live_8f2k", safe to show +} + +export interface Endpoint { + id: string; + name: string; + url: string; + event_types: string[]; // ["*"] means every event type + status: EndpointStatus; // worked out when read: failing if the latest delivery is + // dead, or pending after a failed try; otherwise active. Deleted ones aren't listed. + created_at: string; +} + +// Only in the answer to POST /v1/endpoints: the secret is shown once, never again. +export interface EndpointCreated extends Endpoint { + signing_secret: string; // "whsec_…" +} + +// One row in GET /v1/deliveries +export interface Delivery { + id: string; + status: DeliveryStatus; + attempts: number; // tries in the current cycle (the retry budget); replay resets it to 0 + next_attempt_at: string | null; // when the next try is due; null unless status is "pending" + last_status_code: number | null; // HTTP status of the last try; null if the receiver never answered + last_error: DeliveryError | null; // why the last try failed without a status code; null otherwise + delivered_at: string | null; // set only when status is "delivered" + created_at: string; + event: { id: string; type: string }; + endpoint: { id: string; name: string }; +} + +// One try at a delivery +export interface Attempt { + id: string; + number: number; // 1, 2, 3 … across the whole history, including after replays + started_at: string; + duration_ms: number; + status_code: number | null; // null = no HTTP answer (see error) + error: AttemptError | null; // null when the receiver answered (even with 500) + response_excerpt: string | null; // first 1 KB of the receiver's answer +} + +// GET /v1/deliveries/:id +export interface DeliveryDetail extends Omit { + event: { + id: string; + type: string; + payload: Record; + created_at: string; + }; + endpoint: { id: string; name: string; url: string }; + attempt_log: Attempt[]; // the full history, oldest first, never deleted +} + +// POST /v1/events +export interface EventAccepted { + id: string; + type: string; + created_at: string; + deliveries: number; // how many endpoints will receive it +} + +export interface Page { + data: T[]; + next_cursor: string | null; +} + +export interface ApiError { + error: { + code: string; + message: string; + request_id: string; + details?: { path: string; message: string }[]; + }; +}