From 198596922093722adfb36cf4e21da41187826692 Mon Sep 17 00:00:00 2001 From: Abdikhani Abdirashid Date: Sun, 25 Oct 2026 14:48:19 +0100 Subject: [PATCH 1/4] feat: add dashboard types and mock data from the API contract types.ts is a copy of the types block in docs/api.md; mocks.ts is fake data shaped to it, for building the four screens before the API exists. The mocks cover every state the screens have to render: a delivery mid-retry, one that gave up after eight tries, two that went through, three endpoints with one of each status, and the three error bodies (401, and both ways replay can be refused). Timelines are arithmetic, not decoration: every gap in an attempt log starts when the previous try ended and follows the retry schedule, so the detail screen shows a history that could actually have happened. --- dashboard/src/mocks.ts | 345 +++++++++++++++++++++++++++++++++++++++++ dashboard/src/types.ts | 98 ++++++++++++ 2 files changed, 443 insertions(+) create mode 100644 dashboard/src/mocks.ts create mode 100644 dashboard/src/types.ts diff --git a/dashboard/src/mocks.ts b/dashboard/src/mocks.ts new file mode 100644 index 0000000..1312410 --- /dev/null +++ b/dashboard/src/mocks.ts @@ -0,0 +1,345 @@ +// 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. + +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: 'disabled', + created_at: '2026-10-19T11:02:17.000Z', + }, + ], +}; + +// POST /v1/endpoints — the one answer that carries the signing secret. + +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 only row where Replay does anything. */ +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 }, +}; + +/** Newest first, by `created_at`. */ +export const deliveries: Page = { + data: [pendingDelivery, deliveredDelivery, recoveredDelivery, 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"}', + }, + ], +}; + +// Errors +// +// Every failing request answers with this shape. These three are the ones the +// dashboard has to render: a bad key on Connect, and the two ways Replay can be +// refused. + +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', + }, +}; + +// 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..e99bdc5 --- /dev/null +++ b/dashboard/src/types.ts @@ -0,0 +1,98 @@ +// 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' | 'disabled'; +export type AttemptError = + | 'timeout' + | 'connect_failed' + | 'blocked_address' + | 'redirect' + | 'tls'; + +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, from the latest delivery + 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: AttemptError | 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 }[]; + }; +} From 2657274414c8adaf1025584912c122cb813fc33e Mon Sep 17 00:00:00 2001 From: Abdikhani Abdirashid Date: Thu, 29 Oct 2026 14:24:17 +0100 Subject: [PATCH 2/4] fix: match the dashboard types to the API contract --- dashboard/src/mocks.ts | 5 +++-- dashboard/src/types.ts | 10 +++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/dashboard/src/mocks.ts b/dashboard/src/mocks.ts index 1312410..faaadee 100644 --- a/dashboard/src/mocks.ts +++ b/dashboard/src/mocks.ts @@ -26,7 +26,8 @@ export const me: Me = { // 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. +// 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', @@ -63,7 +64,7 @@ export const endpoints: { data: Endpoint[] } = { { ...billing, event_types: ['invoice.paid'], - status: 'disabled', + status: 'active', // no deliveries yet, and the rule's fallback is active created_at: '2026-10-19T11:02:17.000Z', }, ], diff --git a/dashboard/src/types.ts b/dashboard/src/types.ts index e99bdc5..b337292 100644 --- a/dashboard/src/types.ts +++ b/dashboard/src/types.ts @@ -5,13 +5,16 @@ // 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' | 'disabled'; +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; @@ -29,7 +32,8 @@ export interface Endpoint { name: string; url: string; event_types: string[]; // ["*"] means every event type - status: EndpointStatus; // worked out when read, from the latest delivery + 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; } @@ -45,7 +49,7 @@ export interface Delivery { 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: AttemptError | null; // why the last try failed without a status code; null otherwise + 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 }; From 816373df64c8001c691fc2e863b5a2a4ef92918d Mon Sep 17 00:00:00 2001 From: Abdikhani Abdirashid Date: Thu, 29 Oct 2026 16:59:59 +0100 Subject: [PATCH 3/4] feat: add mock data for the cases added in the API contract update --- dashboard/src/mocks.ts | 100 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 98 insertions(+), 2 deletions(-) diff --git a/dashboard/src/mocks.ts b/dashboard/src/mocks.ts index faaadee..1fa10ad 100644 --- a/dashboard/src/mocks.ts +++ b/dashboard/src/mocks.ts @@ -72,6 +72,14 @@ export const endpoints: { data: Endpoint[] } = { // 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', @@ -129,7 +137,11 @@ const recoveredDelivery: Delivery = { endpoint: { id: n8n.id, name: n8n.name }, }; -/** Gave up after eight tries. The only row where Replay does anything. */ +/** + * 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', @@ -143,9 +155,34 @@ const deadDelivery: Delivery = { 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, deadDelivery], + data: [ + pendingDelivery, + deliveredDelivery, + recoveredDelivery, + endpointDeletedDelivery, + deadDelivery, + ], next_cursor: null, }; @@ -301,6 +338,33 @@ export const pendingDeliveryDetail: DeliveryDetail = { ], }; +/** + * 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. These three are the ones the @@ -331,6 +395,38 @@ export const errorInFlight: ApiError = { }, }; +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 From 746f44f62d533918474c0a0aef92f4bb0c2b963e Mon Sep 17 00:00:00 2001 From: Abdikhani Abdirashid Date: Sat, 31 Oct 2026 14:20:27 +0100 Subject: [PATCH 4/4] fix: clarify error fixtures comment to cover all six error cases --- dashboard/src/mocks.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/dashboard/src/mocks.ts b/dashboard/src/mocks.ts index 1fa10ad..9d33188 100644 --- a/dashboard/src/mocks.ts +++ b/dashboard/src/mocks.ts @@ -367,9 +367,10 @@ export const endpointDeletedDetail: DeliveryDetail = { // Errors // -// Every failing request answers with this shape. These three are the ones the -// dashboard has to render: a bad key on Connect, and the two ways Replay can be -// refused. +// 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: {