Add backend API, running status and 30-day run history

- FastAPI + SQLite backend: tenants, jobs and runs, with an idempotent
  /api/v1/ingest endpoint authenticated by per-collector API keys
- Runs store started_at and finished_at; "Last Run" is the most recent
  finish, and in-progress runs show as Running
- Job modal shows a 30-day daily strip, success rate, and a filterable
  run list with per-run messages
- nginx: HTTPS plus basic auth, ingest endpoint exempt; systemd unit
- manage.py for API keys, job cleanup and demo data; pytest suite

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-04 21:53:24 -06:00
co-authored by Claude Opus 5.5
parent 44e3065efa
commit bf752a904f
25 changed files with 2148 additions and 1 deletions
+527
View File
@@ -0,0 +1,527 @@
import { CONFIG } from './config.js';
import { getApi } from './api.js';
import {
parseDate, formatDateTime, formatTime, formatRelative, formatDuration, formatBytes,
} from './format.js';
const api = await getApi();
// ---------- Domain ----------
// rank drives default sort: worst first
const STATUS = {
failed: { label: 'Failed', icon: '✕', rank: 0 },
warning: { label: 'Warning', icon: '!', rank: 1 },
unknown: { label: 'Unknown', icon: '?', rank: 2 },
running: { label: 'Running', icon: '↻', rank: 3 },
success: { label: 'Success', icon: '✓', rank: 4 },
};
// Anything the backend sends that we don't recognise becomes "unknown" rather than breaking the UI
function normalizeStatus(raw) {
const s = String(raw ?? '').toLowerCase();
return Object.hasOwn(STATUS, s) ? s : 'unknown';
}
function normalizeJob(j) {
return {
id: String(j.id),
company: j.company_name ?? '',
name: j.job_name ?? '',
status: normalizeStatus(j.status),
message: j.message ?? '',
runningSince: parseDate(j.running_since),
// When the most recent run *finished*; a job that's running keeps showing its previous finish
lastRun: parseDate(j.last_run_at),
nextRun: parseDate(j.next_run_at),
};
}
// A running job isn't silently dead, so it's never overdue (a long run can pass its next slot)
const isOverdue = (job, now = Date.now()) =>
job.status !== 'running' && !!job.nextRun && job.nextRun.getTime() + CONFIG.overdueGraceMinutes * 60_000 < now;
// ---------- State ----------
const state = {
jobs: [],
filter: 'all',
search: '',
sort: { key: 'status', dir: 1 },
lastSuccess: null,
};
// ---------- DOM ----------
const $ = (sel) => document.querySelector(sel);
const dom = {
tbody: $('#jobs-body'),
empty: $('#empty-state'),
search: $('#search'),
refreshBtn: $('#refresh-btn'),
lastUpdated: $('#last-updated'),
errorBanner: $('#error-banner'),
dialog: $('#detail-dialog'),
detailTitle: $('#detail-title'),
detailSubtitle: $('#detail-subtitle'),
detailBody: $('#detail-body'),
};
// Tiny element builder. All text goes through append() → text nodes, so API data can never inject HTML.
function el(tag, props = {}, ...children) {
const node = document.createElement(tag);
for (const [k, v] of Object.entries(props)) {
if (v == null || v === false) continue;
if (k === 'class') node.className = v;
else if (k === 'dataset') Object.assign(node.dataset, v);
else node.setAttribute(k, v === true ? '' : v);
}
node.append(...children.flat().filter((c) => c != null && c !== false));
return node;
}
// ---------- Rendering ----------
function statusBadge(status, { asButton = false, opensDialog = false, dataset = null, title = null } = {}) {
const { label, icon } = STATUS[status];
const content = [el('span', { class: 'badge__icon', 'aria-hidden': 'true' }, icon), label];
return asButton
? el('button', {
type: 'button',
class: `badge badge--${status}`,
'aria-haspopup': opensDialog ? 'dialog' : null,
'aria-label': `${label} – view details`,
title,
dataset,
}, content)
: el('span', { class: `badge badge--${status}`, title }, content);
}
const runningTitle = (since) => (since ? `Running since ${formatDateTime(since)}` : null);
function timeCell(date, extra = null) {
if (!date) return el('td', {}, el('span', { class: 'muted' }, 'Never'));
return el('td', {},
el('time', { class: 'time', datetime: date.toISOString(), title: date.toString() },
el('span', {}, formatDateTime(date), extra),
el('span', { class: 'time__rel' }, formatRelative(date))));
}
function renderRow(job) {
const overdueTag = isOverdue(job) ? el('span', { class: 'tag-overdue' }, 'Overdue') : null;
return el('tr', {},
el('td', {}, job.company),
el('td', {}, job.name),
el('td', {}, statusBadge(job.status, {
asButton: true, opensDialog: true, dataset: { jobId: job.id }, title: runningTitle(job.runningSince),
})),
timeCell(job.lastRun),
timeCell(job.nextRun, overdueTag));
}
const collator = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true });
function cmpDate(a, b) {
if (!a && !b) return 0;
if (!a) return 1;
if (!b) return -1;
return a - b;
}
const SORTERS = {
company: (a, b) => collator.compare(a.company, b.company),
name: (a, b) => collator.compare(a.name, b.name),
status: (a, b) => STATUS[a.status].rank - STATUS[b.status].rank,
lastRun: (a, b) => cmpDate(a.lastRun, b.lastRun),
nextRun: (a, b) => cmpDate(a.nextRun, b.nextRun),
};
function visibleJobs() {
const q = state.search.trim().toLowerCase();
const now = Date.now();
const { key, dir } = state.sort;
return state.jobs
.filter((j) => {
if (state.filter === 'overdue') return isOverdue(j, now);
return state.filter === 'all' || j.status === state.filter;
})
.filter((j) => !q || j.company.toLowerCase().includes(q) || j.name.toLowerCase().includes(q))
// Stable tie-breakers keep row order from jumping around on each refresh
.sort((a, b) => SORTERS[key](a, b) * dir || SORTERS.company(a, b) || SORTERS.name(a, b));
}
function renderSummary() {
const now = Date.now();
const counts = { all: state.jobs.length, failed: 0, warning: 0, running: 0, success: 0, overdue: 0 };
for (const j of state.jobs) {
if (j.status in counts) counts[j.status]++;
if (isOverdue(j, now)) counts.overdue++;
}
for (const [k, v] of Object.entries(counts)) {
const node = document.querySelector(`[data-count="${k}"]`);
if (node) node.textContent = v;
}
document.querySelectorAll('.summary-card').forEach((card) => {
card.setAttribute('aria-pressed', String(card.dataset.filter === state.filter));
});
}
function renderSortHeaders() {
document.querySelectorAll('th[aria-sort]').forEach((th) => {
const key = th.querySelector('.sort').dataset.sort;
th.setAttribute('aria-sort',
key === state.sort.key ? (state.sort.dir === 1 ? 'ascending' : 'descending') : 'none');
});
}
function render() {
const rows = visibleJobs();
dom.tbody.replaceChildren(...rows.map(renderRow));
dom.empty.hidden = rows.length > 0;
renderSummary();
renderSortHeaders();
}
// ---------- Data loading ----------
let inFlight = null;
let pollTimer = null;
function refresh() {
if (inFlight) return inFlight; // de-dupe overlapping manual + scheduled refreshes
dom.refreshBtn.disabled = true;
inFlight = (async () => {
try {
const data = await api.listJobs();
state.jobs = data.jobs.map(normalizeJob);
state.lastSuccess = new Date();
dom.errorBanner.hidden = true;
dom.lastUpdated.textContent = `Updated ${formatTime(state.lastSuccess)}`;
} catch (err) {
// Keep showing stale data, but make it obvious it's stale
dom.errorBanner.textContent = state.lastSuccess
? `Couldn't refresh (${err.message}). Showing data from ${formatTime(state.lastSuccess)}.`
: `Couldn't load backup status (${err.message}).`;
dom.errorBanner.hidden = false;
dom.lastUpdated.textContent = 'Update failed';
console.error(err);
} finally {
render();
dom.refreshBtn.disabled = false;
inFlight = null;
}
})();
return inFlight;
}
function schedulePoll() {
clearTimeout(pollTimer);
pollTimer = setTimeout(async () => {
await refresh();
schedulePoll();
}, CONFIG.refreshIntervalMs);
}
// Don't poll from background tabs (e.g. a wall display that's been switched away); catch up on return
document.addEventListener('visibilitychange', () => {
if (document.hidden) clearTimeout(pollTimer);
else refresh().then(schedulePoll);
});
// ---------- Detail modal ----------
// Every async result checks reqId first, so a slow response for job A can never paint over job B.
const modal = {
reqId: 0,
job: null,
detail: null,
runs: null, // null = loading, [] = none
runsError: null,
dayFilter: null, // local YYYY-MM-DD, or null for all days
selectedRunId: null, // null = the job's latest finished run (from the detail call)
runMessage: null, // { status, text, startedAt } for the selected run
runMessageError: null,
};
// Colour of a day cell: the worst thing that happened that day
const DAY_SEVERITY = ['failed', 'warning', 'unknown', 'running', 'success'];
const pad2 = (n) => String(n).padStart(2, '0');
const localDayKey = (d) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
const dayLabelFmt = new Intl.DateTimeFormat(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
const dayTickFmt = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' });
function normalizeRun(r) {
return {
id: String(r.id),
status: normalizeStatus(r.status),
startedAt: parseDate(r.started_at),
finishedAt: parseDate(r.finished_at),
duration: r.duration_seconds,
size: r.size_bytes,
};
}
function buildDays(runs, count) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const days = [];
for (let i = count - 1; i >= 0; i--) {
const date = new Date(today);
date.setDate(date.getDate() - i);
days.push({ key: localDayKey(date), date, counts: {}, total: 0 });
}
const byKey = new Map(days.map((d) => [d.key, d]));
for (const run of runs) {
const day = byKey.get(localDayKey(run.startedAt));
if (!day) continue;
day.counts[run.status] = (day.counts[run.status] ?? 0) + 1;
day.total++;
}
for (const day of days) day.status = day.total ? DAY_SEVERITY.find((s) => day.counts[s]) : 'none';
return days;
}
function describeCounts(counts) {
return DAY_SEVERITY.filter((s) => counts[s])
.map((s) => `${counts[s]} ${STATUS[s].label.toLowerCase()}`)
.join(', ');
}
function detailRow(label, value) {
if (value == null || value === '') return [];
return [el('dt', {}, label), el('dd', {}, value)];
}
function renderSummarySection() {
const { job, detail } = modal;
const status = detail ? normalizeStatus(detail.status) : job.status;
const runningSince = detail ? parseDate(detail.running_since) : job.runningSince;
const lastStatus = detail?.last_status ? normalizeStatus(detail.last_status) : null;
dom.detailTitle.textContent = detail?.job_name ?? job.name;
dom.detailSubtitle.textContent = detail?.company_name ?? job.company;
return el('section', {},
el('div', { class: 'detail-status' },
statusBadge(status),
runningSince ? el('span', { class: 'muted' }, `since ${formatDateTime(runningSince)}`) : null),
el('dl', { class: 'detail-grid' },
// While a job is running, its "last run" is the previous one — say how that went
status === 'running' && lastStatus ? detailRow('Previous result', STATUS[lastStatus].label) : [],
detailRow('Last finished', formatDateTime(detail ? parseDate(detail.last_run_at) : job.lastRun)),
detailRow('Next scheduled', formatDateTime(detail ? parseDate(detail.next_run_at) : job.nextRun)),
detailRow('Duration', formatDuration(detail?.duration_seconds)),
detailRow('Size', formatBytes(detail?.size_bytes)),
detailRow('Source', detail?.source_system)));
}
function renderHistorySection() {
const section = el('section', { id: 'detail-history' },
el('h3', { class: 'detail-section-title' }, `Last ${CONFIG.historyDays} days`));
if (modal.runsError) {
section.append(el('p', { class: 'banner banner--error' }, `Couldn't load run history: ${modal.runsError}`));
return section;
}
if (!modal.runs) {
section.append(el('p', { class: 'muted' }, 'Loading history…'));
return section;
}
const days = buildDays(modal.runs, CONFIG.historyDays);
const finished = modal.runs.filter((r) => r.finishedAt);
const ok = finished.filter((r) => r.status === 'success').length;
const totals = {};
for (const r of modal.runs) totals[r.status] = (totals[r.status] ?? 0) + 1;
section.append(
el('p', { class: 'history-stats' },
modal.runs.length
? `${modal.runs.length} runs · ${finished.length ? Math.round((ok / finished.length) * 100) : 0}% successful · ${describeCounts(totals)}`
: 'No runs recorded in this period.'),
el('div', { class: 'day-strip', role: 'group', 'aria-label': 'Daily results, oldest to newest. Select a day to filter runs.' },
days.map((d) => {
const label = `${dayLabelFmt.format(d.date)}: ${d.total ? describeCounts(d.counts) : 'no runs'}`;
return el('button', {
type: 'button',
class: `day day--${d.status}`,
title: label,
'aria-label': label,
'aria-pressed': String(modal.dayFilter === d.key),
disabled: !d.total,
dataset: { day: d.key },
});
})),
el('div', { class: 'day-strip__ticks muted', 'aria-hidden': 'true' },
el('span', {}, dayTickFmt.format(days[0].date)),
el('span', {}, 'Today')),
);
const rows = modal.dayFilter
? modal.runs.filter((r) => localDayKey(r.startedAt) === modal.dayFilter)
: modal.runs;
if (!rows.length) return section;
if (modal.dayFilter) {
const date = days.find((d) => d.key === modal.dayFilter)?.date;
section.append(el('p', { class: 'muted history-filter' },
`Showing ${dayLabelFmt.format(date)} · `,
el('button', { type: 'button', class: 'link', dataset: { day: modal.dayFilter } }, 'Show all days')));
}
section.append(el('div', { class: 'runs-wrap' },
el('table', { class: 'runs' },
el('thead', {}, el('tr', {},
['Status', 'Started', 'Finished', 'Duration', 'Size'].map((h) => el('th', { scope: 'col' }, h)))),
el('tbody', {}, rows.map((r) => el('tr', { class: r.id === modal.selectedRunId ? 'is-selected' : null },
el('td', {}, statusBadge(r.status, { asButton: true, dataset: { runId: r.id } })),
el('td', {}, formatDateTime(r.startedAt)),
el('td', {}, r.finishedAt ? formatDateTime(r.finishedAt) : el('span', { class: 'muted' }, 'In progress')),
el('td', {}, formatDuration(r.duration) ?? '—'),
el('td', {}, formatBytes(r.size) ?? '—')))))));
return section;
}
function renderMessageSection() {
const { detail, job } = modal;
let status;
let text;
let heading = 'Message';
if (modal.selectedRunId) {
const run = modal.runs?.find((r) => r.id === modal.selectedRunId);
heading = `Message · run started ${formatDateTime(run?.startedAt)}`;
status = run?.status ?? 'unknown';
text = modal.runMessageError
? `Couldn't load this run: ${modal.runMessageError}`
: modal.runMessage?.text ?? 'Loading…';
} else {
status = detail?.last_status ? normalizeStatus(detail.last_status) : job.status;
text = detail?.message ?? job.message;
}
const extras = !modal.selectedRunId && detail?.details && typeof detail.details === 'object'
? Object.entries(detail.details).flatMap(([k, v]) =>
detailRow(k.replace(/_/g, ' '), typeof v === 'object' ? JSON.stringify(v) : String(v)))
: modal.runMessage?.extras ?? [];
return el('section', { id: 'detail-message' },
el('h3', { class: 'detail-section-title' }, heading),
el('pre', { class: `message message--${status}` }, text || 'No message reported.'),
extras.length ? el('h3', { class: 'detail-section-title' }, 'Additional details') : null,
extras.length ? el('dl', { class: 'detail-grid' }, extras) : null);
}
function renderModal({ loading = false, error = null } = {}) {
dom.detailBody.replaceChildren(...[
renderSummarySection(),
loading ? el('p', { class: 'muted' }, 'Loading full details…') : null,
error ? el('p', { class: 'banner banner--error' }, `Couldn't load full details: ${error}`) : null,
renderMessageSection(),
renderHistorySection(),
].filter(Boolean));
}
// Re-render just the history section, keeping the runs table's scroll position
function renderHistory() {
const wrap = dom.detailBody.querySelector('.runs-wrap');
const scrollTop = wrap?.scrollTop ?? 0;
dom.detailBody.querySelector('#detail-history')?.replaceWith(renderHistorySection());
const newWrap = dom.detailBody.querySelector('.runs-wrap');
if (newWrap) newWrap.scrollTop = scrollTop;
}
function renderMessage() {
dom.detailBody.querySelector('#detail-message')?.replaceWith(renderMessageSection());
}
async function openDetail(jobId) {
const job = state.jobs.find((j) => j.id === jobId);
if (!job) return;
const reqId = ++modal.reqId;
Object.assign(modal, {
job, detail: null, runs: null, runsError: null, dayFilter: null,
selectedRunId: null, runMessage: null, runMessageError: null,
});
renderModal({ loading: true }); // show what we have immediately
if (!dom.dialog.open) dom.dialog.showModal();
const current = () => reqId === modal.reqId && dom.dialog.open;
const detailP = api.getJob(jobId).then(
(detail) => { if (current()) { modal.detail = detail; renderModal(); } },
(err) => { if (current()) renderModal({ error: err.message }); },
);
const runsP = api.listRuns(jobId, CONFIG.historyDays).then(
(data) => {
if (!current()) return;
modal.runs = (data.runs ?? []).map(normalizeRun).filter((r) => r.startedAt);
renderHistory();
},
(err) => { if (current()) { modal.runsError = err.message; renderHistory(); } },
);
await Promise.all([detailP, runsP]);
}
async function selectRun(runId) {
const reqId = modal.reqId;
modal.selectedRunId = runId;
modal.runMessage = null;
modal.runMessageError = null;
renderHistory();
renderMessage();
try {
const run = await api.getRun(runId);
if (reqId !== modal.reqId || modal.selectedRunId !== runId) return;
modal.runMessage = {
text: run.message,
extras: Object.entries(run.details ?? {}).flatMap(([k, v]) =>
detailRow(k.replace(/_/g, ' '), typeof v === 'object' ? JSON.stringify(v) : String(v))),
};
} catch (err) {
if (reqId !== modal.reqId || modal.selectedRunId !== runId) return;
modal.runMessageError = err.message;
}
renderMessage();
dom.detailBody.querySelector('#detail-message')?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
// ---------- Events ----------
dom.tbody.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-job-id]');
if (btn) openDetail(btn.dataset.jobId);
});
dom.dialog.addEventListener('click', (e) => {
// Click on the backdrop (the dialog element itself, outside its content) or the close button
if (e.target === dom.dialog || e.target.closest('[data-close]')) dom.dialog.close();
});
dom.detailBody.addEventListener('click', (e) => {
const day = e.target.closest('button[data-day]');
if (day) {
modal.dayFilter = modal.dayFilter === day.dataset.day ? null : day.dataset.day;
renderHistory();
return;
}
const run = e.target.closest('button[data-run-id]');
if (run) selectRun(run.dataset.runId);
});
document.querySelector('.summary').addEventListener('click', (e) => {
const card = e.target.closest('.summary-card');
if (!card) return;
state.filter = card.dataset.filter;
render();
});
document.querySelector('.jobs thead').addEventListener('click', (e) => {
const btn = e.target.closest('.sort');
if (!btn) return;
const key = btn.dataset.sort;
state.sort = state.sort.key === key ? { key, dir: -state.sort.dir } : { key, dir: 1 };
render();
});
dom.search.addEventListener('input', () => {
state.search = dom.search.value;
render();
});
dom.refreshBtn.addEventListener('click', () => refresh().then(schedulePoll));
// ---------- Boot ----------
refresh().then(schedulePoll);