Simplify Relay Lab dashboard and retry handling
This commit is contained in:
+26
-19
@@ -1,6 +1,7 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const states = ['queued', 'running', 'delivered', 'dead'];
|
const states = ['queued', 'running', 'delivered', 'dead'];
|
||||||
|
const pollInterval = 750;
|
||||||
const byId = (id) => document.getElementById(id);
|
const byId = (id) => document.getElementById(id);
|
||||||
let lastRequest;
|
let lastRequest;
|
||||||
let sending = false;
|
let sending = false;
|
||||||
@@ -15,16 +16,19 @@ function renderList(id, items, toRow, emptyMessage) {
|
|||||||
byId(id).replaceChildren(...(items.length ? items.map(toRow) : [element('li', emptyMessage)]));
|
byId(id).replaceChildren(...(items.length ? items.map(toRow) : [element('li', emptyMessage)]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderPipeline({ queued = 0, running = 0, delivered = 0, dead = 0 }) {
|
||||||
|
byId('pipeline-queued').textContent = `${queued} waiting`;
|
||||||
|
byId('pipeline-running').textContent = `${running} active / 2 workers`;
|
||||||
|
byId('pipeline-outcome').textContent = `${delivered} delivered / ${dead} dead-lettered`;
|
||||||
|
byId('pipeline').dataset.active = String(running > 0);
|
||||||
|
}
|
||||||
|
|
||||||
function render(state) {
|
function render(state) {
|
||||||
const latest = state.events[0];
|
const latest = state.events[0];
|
||||||
byId('backend-event').textContent = latest
|
byId('backend-event').textContent = latest
|
||||||
? `${latest.job_id.slice(0, 8)} · ${latest.message}`
|
? `${latest.job_id.slice(0, 8)} · ${latest.message}`
|
||||||
: 'No queue events yet.';
|
: 'No queue events yet.';
|
||||||
const { queued = 0, running = 0, delivered = 0, dead = 0 } = state.counts;
|
renderPipeline(state.counts);
|
||||||
byId('pipeline-queued').textContent = `${queued} waiting`;
|
|
||||||
byId('pipeline-running').textContent = `${running} active / 2 workers`;
|
|
||||||
byId('pipeline-outcome').textContent = `${delivered} delivered / ${dead} dead-lettered`;
|
|
||||||
byId('pipeline').dataset.active = String(running > 0);
|
|
||||||
byId('metrics').replaceChildren(...states.map((name) => {
|
byId('metrics').replaceChildren(...states.map((name) => {
|
||||||
const cell = element('div', '');
|
const cell = element('div', '');
|
||||||
cell.dataset.state = name;
|
cell.dataset.state = name;
|
||||||
@@ -66,45 +70,48 @@ async function poll() {
|
|||||||
connection.textContent = online ? 'Queue connected' : 'Queue disconnected';
|
connection.textContent = online ? 'Queue connected' : 'Queue disconnected';
|
||||||
connection.dataset.online = String(online);
|
connection.dataset.online = String(online);
|
||||||
}
|
}
|
||||||
setTimeout(poll, 750);
|
setTimeout(poll, pollInterval);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submit(request, button) {
|
async function submit(request, button) {
|
||||||
if (sending) return;
|
if (sending) return;
|
||||||
sending = true;
|
sending = true;
|
||||||
byId('backend-request').textContent = 'Sending request…';
|
const inspector = byId('backend-request');
|
||||||
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
inspector.textContent = 'Sending request…';
|
||||||
button.animate(
|
let responseSummary = 'No API response available.';
|
||||||
[{ transform: 'scale(.96)', boxShadow: '0 0 0 0 #bd93f9aa' },
|
|
||||||
{ transform: 'scale(1)', boxShadow: '0 0 0 8px #bd93f900' }],
|
|
||||||
{ duration: 320, easing: 'ease-out' },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
|
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||||
|
button.animate?.([
|
||||||
|
{ transform: 'scale(.96)', boxShadow: '0 0 0 0 #bd93f9aa' },
|
||||||
|
{ transform: 'scale(1)', boxShadow: '0 0 0 8px #bd93f900' },
|
||||||
|
], { duration: 320, easing: 'ease-out' });
|
||||||
|
}
|
||||||
const response = await fetch('/api/jobs', {
|
const response = await fetch('/api/jobs', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(request),
|
body: JSON.stringify(request),
|
||||||
});
|
});
|
||||||
|
responseSummary = `HTTP ${response.status} · Response was not valid JSON.`;
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
byId('backend-request').textContent = `HTTP ${response.status} · ${JSON.stringify(result)}`;
|
responseSummary = `HTTP ${response.status} · ${JSON.stringify(result)}`;
|
||||||
if (!response.ok) throw new Error(result.error || 'Request failed.');
|
if (!response.ok) throw new Error(result.error || 'Request failed.');
|
||||||
lastRequest = request;
|
lastRequest = request;
|
||||||
byId('repeat').disabled = false;
|
byId('repeat').disabled = false;
|
||||||
byId('status').textContent = `${result.created ? 'Queued job' : 'Reused job'} ${result.id.slice(0, 8)}.`;
|
byId('status').textContent = `${result.created ? 'Queued job' : 'Reused job'} ${result.id.slice(0, 8)}.`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (byId('backend-request').textContent === 'Sending request…') {
|
|
||||||
byId('backend-request').textContent = 'No API response available.';
|
|
||||||
}
|
|
||||||
byId('status').textContent = error.message;
|
byId('status').textContent = error.message;
|
||||||
} finally {
|
} finally {
|
||||||
|
inspector.textContent = responseSummary;
|
||||||
sending = false;
|
sending = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const button of document.querySelectorAll('[data-scenario]')) {
|
for (const button of document.querySelectorAll('[data-scenario]')) {
|
||||||
button.addEventListener('click', () => submit({ key: crypto.randomUUID(), scenario: button.dataset.scenario }, button));
|
button.addEventListener('click', () => {
|
||||||
|
const request = { key: crypto.randomUUID(), scenario: button.dataset.scenario };
|
||||||
|
submit(request, button);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
byId('repeat').addEventListener('click', (event) => submit(lastRequest, event.currentTarget));
|
byId('repeat').addEventListener('click', (event) => submit(lastRequest, event.currentTarget));
|
||||||
poll();
|
poll();
|
||||||
|
|||||||
+4
-2
@@ -4,6 +4,8 @@ import sqlite3
|
|||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
MAX_ATTEMPTS = 3
|
||||||
|
|
||||||
|
|
||||||
class Conflict(Exception):
|
class Conflict(Exception):
|
||||||
"""An idempotency key was reused for a different request."""
|
"""An idempotency key was reused for a different request."""
|
||||||
@@ -77,7 +79,7 @@ class QueueStore:
|
|||||||
(now,),
|
(now,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
for row in expired:
|
for row in expired:
|
||||||
state = "dead" if row["attempts"] >= 3 else "queued"
|
state = "dead" if row["attempts"] >= MAX_ATTEMPTS else "queued"
|
||||||
db.execute("""
|
db.execute("""
|
||||||
UPDATE jobs SET state = ?, available_at = ?, lease_token = NULL,
|
UPDATE jobs SET state = ?, available_at = ?, lease_token = NULL,
|
||||||
lease_until = NULL WHERE id = ?
|
lease_until = NULL WHERE id = ?
|
||||||
@@ -104,7 +106,7 @@ class QueueStore:
|
|||||||
delay = 2 ** attempt
|
delay = 2 ** attempt
|
||||||
if success:
|
if success:
|
||||||
state, message = "delivered", "Consumer acknowledged the event"
|
state, message = "delivered", "Consumer acknowledged the event"
|
||||||
elif attempt >= 3:
|
elif attempt >= MAX_ATTEMPTS:
|
||||||
state, message = "dead", "Consumer failed · attempts exhausted"
|
state, message = "dead", "Consumer failed · attempts exhausted"
|
||||||
else:
|
else:
|
||||||
state, message = "queued", f"Consumer failed · retry in {delay}s"
|
state, message = "queued", f"Consumer failed · retry in {delay}s"
|
||||||
|
|||||||
Reference in New Issue
Block a user