From 4adf5a85934fe4f9d4d67f221125b6b0c8794120 Mon Sep 17 00:00:00 2001 From: Grant Date: Fri, 8 May 2026 10:41:44 -0500 Subject: [PATCH] Admin SPA: surface webhook delivery history (DLQ visible) The /v1/admin/webhook-deliveries endpoints from v0.1.0:43 were operator-actionable via curl but invisible in the dashboard. Adds a "Delivery history" section to the Webhooks page showing recent deliveries with a status filter (defaults to "Failed (DLQ)" so the problem case is what an operator sees first). Each row shows created-at, event type, status badge (delivered / failed / pending), attempt count, last status code, and last_error inline beneath the status when present (so operators don't have to chase a separate "details" view to know why a delivery failed). Non-delivered rows get a Retry button that re-queues via the existing POST /v1/admin/webhook-deliveries/:id/retry; the worker picks up the retried row on its next 5s tick. No backend changes. The endpoints landed in :43; this commit is just the front-end surface. --- licensing-service/web/index.html | 89 ++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/licensing-service/web/index.html b/licensing-service/web/index.html index 1dfbd35..7ecc9ac 100644 --- a/licensing-service/web/index.html +++ b/licensing-service/web/index.html @@ -2089,6 +2089,95 @@ The request will be refused if there are licenses or invoices tied to it — use } catch (e) { target.appendChild(plainCard([err(e.message)])) } + + // ----- Delivery history ----- + // + // Outbound deliveries get retried with exponential backoff up to + // 10 attempts; after that they're "dead-lettered" — sat in the + // DB unreachable. The new admin endpoint (v0.1.0:43) exposes them + // so operators can investigate and manually re-queue. + const status = el('select', { class: 'input', style: 'min-width:10rem' }, [ + el('option', { value: 'all' }, 'All'), + el('option', { value: 'pending' }, 'Pending (in retry queue)'), + el('option', { value: 'delivered' }, 'Delivered'), + el('option', { value: 'failed', selected: 'selected' }, 'Failed (DLQ)'), + ]) + const reload = el('button', { class: 'btn sm secondary', onclick: () => loadDeliveries() }, 'Reload') + const deliveriesContainer = el('div') + + async function loadDeliveries () { + deliveriesContainer.innerHTML = '' + deliveriesContainer.appendChild(el('p', { class: 'muted' }, 'Loading…')) + try { + const params = new URLSearchParams({ status: status.value, limit: '100' }) + const j = await api('/v1/admin/webhook-deliveries?' + params.toString()) + const ds = j.deliveries || [] + deliveriesContainer.innerHTML = '' + + if (ds.length === 0) { + const empty = status.value === 'failed' + ? 'No failed deliveries — all webhooks are landing or in flight.' + : 'No deliveries match this filter.' + deliveriesContainer.appendChild(el('p', { class: 'muted', style: 'margin:8px 0' }, empty)) + return + } + + const rows = ds.map((d) => { + // status: delivered (delivered_at set) | failed (no next + attempts > 0) | pending (next set) + let pillCls = 'badge b-warning' + let pillDot = 'warn' + let pillText = 'pending' + if (d.delivered_at) { + pillCls = 'badge b-success' + pillDot = 'ok' + pillText = 'delivered' + } else if (!d.next_attempt_at && d.attempt_count > 0) { + pillCls = 'badge b-danger' + pillDot = 'err' + pillText = 'failed (DLQ)' + } + const pill = el('span', { class: pillCls }, [el('span', { class: 'dot ' + pillDot }), pillText]) + const lastErr = d.last_error + ? el('div', { class: 'muted', style: 'font-size:0.85em; margin-top:4px; word-break:break-all' }, d.last_error) + : null + return el('tr', null, [ + el('td', { class: 'muted' }, fmtDate(d.created_at)), + el('td', null, d.event_type), + el('td', null, [pill, lastErr].filter(Boolean)), + el('td', { class: 'muted' }, String(d.attempt_count)), + el('td', { class: 'muted' }, d.last_status_code ? String(d.last_status_code) : '—'), + el('td', null, d.delivered_at + ? null + : el('button', { class: 'btn sm secondary', onclick: async () => { + try { + await api('/v1/admin/webhook-deliveries/' + d.id + '/retry', { method: 'POST' }) + loadDeliveries() + } catch (er) { alert(er.message) } + }}, 'Retry')), + ]) + }) + deliveriesContainer.appendChild(el('table', { class: 'data' }, [ + el('thead', null, el('tr', null, + ['Created', 'Event', 'Status', 'Attempts', 'Last code', ''] + .map((h) => el('th', null, h)) + )), + el('tbody', null, rows), + ])) + } catch (e) { + deliveriesContainer.innerHTML = '' + deliveriesContainer.appendChild(err(e.message)) + } + } + status.addEventListener('change', loadDeliveries) + + target.appendChild(plainCard([ + el('h3', { style: 'margin:0 0 8px' }, 'Delivery history'), + el('p', { class: 'muted', style: 'margin:0 0 12px' }, + 'Defaults to "Failed" so the DLQ is visible at a glance. Failed deliveries are dead-lettered after 10 retry attempts (~7h backoff window). "Retry" re-queues the delivery for the worker on its next 5s tick.'), + el('div', { style: 'display:flex; gap:8px; align-items:center; margin-bottom:12px' }, [status, reload]), + deliveriesContainer, + ])) + loadDeliveries() } // -------- Audit log --------