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
+61
View File
@@ -0,0 +1,61 @@
import { CONFIG } from './config.js';
export class ApiError extends Error {
constructor(message, status = null) {
super(message);
this.name = 'ApiError';
this.status = status;
}
}
async function request(path) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), CONFIG.requestTimeoutMs);
try {
const res = await fetch(`${CONFIG.apiBaseUrl}${path}`, {
headers: { Accept: 'application/json' },
credentials: 'same-origin', // ready for cookie/session auth behind nginx
signal: controller.signal,
});
// 401 means the nginx/SSO session lapsed; a reload sends the user back through sign-in
if (res.status === 401) throw new ApiError('Your session has expired. Reload the page to sign in again', 401);
if (!res.ok) throw new ApiError(`Server returned HTTP ${res.status}`, res.status);
return await res.json();
} catch (err) {
if (err.name === 'AbortError') throw new ApiError('Request timed out');
if (err instanceof ApiError) throw err;
throw new ApiError(`Network error: ${err.message}`);
} finally {
clearTimeout(timer);
}
}
const httpApi = {
/** GET /jobs → { generated_at, jobs: Job[] } */
async listJobs() {
const data = await request('/jobs');
if (!Array.isArray(data?.jobs)) throw new ApiError('Unexpected response shape from /jobs');
return data;
},
/** GET /jobs/:id → JobDetail */
getJob(id) {
return request(`/jobs/${encodeURIComponent(id)}`);
},
/** GET /jobs/:id/runs?days=N → { job_id, since, runs: RunSummary[] } (newest first, no messages) */
listRuns(id, days) {
return request(`/jobs/${encodeURIComponent(id)}/runs?days=${encodeURIComponent(days)}`);
},
/** GET /runs/:id → Run (with message and details) */
getRun(id) {
return request(`/runs/${encodeURIComponent(id)}`);
},
};
// Mock is loaded lazily so it never ships in the production request path.
export async function getApi() {
if (CONFIG.useMock) {
const { mockApi } = await import('./mock.js');
return mockApi;
}
return httpApi;
}
+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);
+15
View File
@@ -0,0 +1,15 @@
// Central runtime config. Kept as a static file so ops can change it without a build.
const params = new URLSearchParams(window.location.search);
export const CONFIG = Object.freeze({
apiBaseUrl: '/api/v1',
refreshIntervalMs: 60_000,
requestTimeoutMs: 15_000,
// A job whose next scheduled run is this far in the past is flagged "Overdue".
// Catches the silent failure mode: a job stops reporting and stays green forever.
overdueGraceMinutes: 30,
// Run history shown in the job modal (the API caps this at 30)
historyDays: 30,
// ?mock=1 → use in-browser sample data, no backend needed
useMock: params.has('mock'),
});
+44
View File
@@ -0,0 +1,44 @@
const dateTimeFmt = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' });
const timeFmt = new Intl.DateTimeFormat(undefined, { timeStyle: 'medium' });
const relFmt = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
const REL_UNITS = [
['year', 31_536_000], ['month', 2_592_000], ['week', 604_800],
['day', 86_400], ['hour', 3_600], ['minute', 60],
];
export function parseDate(value) {
if (!value) return null;
const d = new Date(value);
return Number.isNaN(d.getTime()) ? null : d;
}
export const formatDateTime = (d) => (d ? dateTimeFmt.format(d) : '—');
export const formatTime = (d) => timeFmt.format(d);
export function formatRelative(d, now = Date.now()) {
if (!d) return '';
const diffSec = Math.round((d.getTime() - now) / 1000);
for (const [unit, secs] of REL_UNITS) {
if (Math.abs(diffSec) >= secs) return relFmt.format(Math.round(diffSec / secs), unit);
}
return relFmt.format(diffSec, 'second');
}
export function formatDuration(totalSec) {
if (totalSec == null || Number.isNaN(Number(totalSec))) return null;
const s = Math.max(0, Math.round(totalSec));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
return [h && `${h}h`, (h || m) && `${m}m`, `${sec}s`].filter(Boolean).join(' ');
}
export function formatBytes(bytes) {
if (bytes == null || Number.isNaN(Number(bytes))) return null;
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
let n = Number(bytes);
let i = 0;
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
return `${n.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
+129
View File
@@ -0,0 +1,129 @@
// Sample data matching the API contract. Timestamps are relative to page load so it always looks "live".
const now = Date.now();
const MIN = 60_000;
const HR = 60 * MIN;
const DAY = 24 * HR;
const iso = (offsetMs) => new Date(now + offsetMs).toISOString();
// every_h drives the generated run history; it isn't part of the API.
const JOBS = [
{
id: 1, company_name: 'Acme Corp', job_name: 'File Server – Nightly', status: 'success', every_h: 24,
last_run_at: iso(-6 * HR), next_run_at: iso(18 * HR),
message: 'Job completed successfully. 1,284 files processed.',
source_system: 'Veeam B&R', duration_seconds: 2840, size_bytes: 128_849_018_880,
},
{
id: 2, company_name: 'Acme Corp', job_name: 'SQL01 – Transaction Logs', status: 'warning', every_h: 1,
last_run_at: iso(-25 * MIN), next_run_at: iso(35 * MIN),
message: 'Processing SQL01 Warning:\nFailed to truncate transaction logs for database "ERP_PROD".\nLog backup completed but truncation was skipped.',
source_system: 'Veeam B&R', duration_seconds: 312, size_bytes: 2_147_483_648,
details: { retry_count: 0, repository: 'REPO-ONSITE-01' },
},
{
// Running now; "last run" is still the previous completion
id: 7, company_name: 'Acme Corp', job_name: 'Hyper-V Hosts – Daily', status: 'running', every_h: 24,
running_since: iso(-12 * MIN), last_status: 'success',
last_run_at: iso(-DAY + 40 * MIN), next_run_at: iso(DAY - 12 * MIN),
message: 'Completed successfully. 6 VMs processed.',
source_system: 'Veeam B&R', duration_seconds: 3120, size_bytes: 412_316_860_416,
},
{
id: 3, company_name: 'Bluewater Dental', job_name: 'Workstations – Daily', status: 'failed', every_h: 24,
last_run_at: iso(-3 * HR), next_run_at: iso(21 * HR),
message: 'Error: Unable to connect to backup repository \\\\NAS02\\Backups.\nThe network path was not found. (0x80070035)\nJob terminated after 3 retries.',
source_system: 'Datto', duration_seconds: 95,
details: { retry_count: 3, target: '\\\\NAS02\\Backups' },
},
{
id: 4, company_name: 'Bluewater Dental', job_name: 'Imaging Server – Hourly', status: 'success', every_h: 1,
last_run_at: iso(-50 * MIN), next_run_at: iso(10 * MIN),
message: 'Completed.', source_system: 'Datto', duration_seconds: 420, size_bytes: 8_589_934_592,
},
{
// Green status but next run is in the past → should show as Overdue
id: 5, company_name: 'Northfield Logistics', job_name: 'Exchange – Nightly', status: 'success', every_h: 24,
last_run_at: iso(-30 * HR), next_run_at: iso(-6 * HR),
message: 'Completed successfully.', source_system: 'Acronis', duration_seconds: 5400,
},
{
// Never run / unrecognised status → rendered as Unknown
id: 6, company_name: 'Northfield Logistics', job_name: 'New NAS – Weekly', status: null,
last_run_at: null, next_run_at: iso(2 * DAY),
message: '', source_system: 'Synology Hyper Backup',
},
];
const MESSAGES = {
success: 'Job completed successfully.',
warning: 'Completed with warnings: 1 file skipped (file in use).',
failed: 'Error: Target repository unreachable. Job terminated.',
};
// Deterministic pseudo-random so history doesn't reshuffle on every modal open
function rng(seed) {
let x = seed;
return () => ((x = (x * 1_103_515_245 + 12_345) % 2_147_483_648) / 2_147_483_648);
}
const RUNS = new Map(); // job id → runs, newest first
for (const job of JOBS) {
const runs = [];
if (job.running_since) {
runs.push({ id: `${job.id}-r`, status: 'running', started_at: job.running_since, finished_at: null, message: 'In progress…' });
}
if (job.last_run_at && job.every_h) {
const rand = rng(job.id * 7919);
const dur = (job.duration_seconds ?? 600) * 1000;
let end = Date.parse(job.last_run_at);
for (let i = 0; end - dur > now - 30 * DAY; i++, end -= job.every_h * HR) {
const r = rand();
const status = i === 0 ? (job.last_status ?? job.status) : r < 0.03 ? 'failed' : r < 0.1 ? 'warning' : 'success';
const thisDur = dur * (0.8 + rand() * 0.4);
runs.push({
id: `${job.id}-${i}`,
status,
started_at: new Date(end - thisDur).toISOString(),
finished_at: new Date(end).toISOString(),
duration_seconds: Math.round(thisDur / 1000),
size_bytes: status === 'failed' ? null : job.size_bytes,
message: i === 0 ? job.message : MESSAGES[status],
details: i === 0 ? job.details ?? {} : {},
});
}
}
RUNS.set(String(job.id), runs);
}
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
const summary = ({ details, every_h, last_status, duration_seconds, size_bytes, ...rest }) => rest;
export const mockApi = {
async listJobs() {
await delay(250);
return { generated_at: new Date().toISOString(), jobs: JOBS.map(summary) };
},
async getJob(id) {
await delay(200);
const job = JOBS.find((j) => String(j.id) === String(id));
if (!job) throw new Error('Job not found');
const { every_h, ...rest } = job;
return { last_status: job.status === 'running' ? null : job.status, ...rest, details: job.details ?? {} };
},
async listRuns(id, days) {
await delay(300);
const since = now - days * DAY;
const runs = (RUNS.get(String(id)) ?? [])
.filter((r) => Date.parse(r.started_at) >= since)
.map(({ message, details, ...r }) => r);
return { job_id: id, since: new Date(since).toISOString(), runs };
},
async getRun(id) {
await delay(150);
for (const runs of RUNS.values()) {
const run = runs.find((r) => r.id === id);
if (run) return { details: {}, ...run };
}
throw new Error('Run not found');
},
};