diff --git a/api.js b/api.js
new file mode 100644
index 0000000..6ba1f27
--- /dev/null
+++ b/api.js
@@ -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;
+}
\ No newline at end of file
diff --git a/config.js b/config.js
new file mode 100644
index 0000000..9a5f73a
--- /dev/null
+++ b/config.js
@@ -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'),
+});
\ No newline at end of file
diff --git a/format.js b/format.js
new file mode 100644
index 0000000..92459d0
--- /dev/null
+++ b/format.js
@@ -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]}`;
+}
\ No newline at end of file
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..1ffdef3
--- /dev/null
+++ b/index.html
@@ -0,0 +1,85 @@
+
+
+
+
+
+ Backup Monitor
+
+
+
+
+
+
+ Backup Monitor
+
+ Loading…
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+
+
+
+
+
No backup jobs match the current filter.
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/styles.css b/styles.css
new file mode 100644
index 0000000..46619d7
--- /dev/null
+++ b/styles.css
@@ -0,0 +1,341 @@
+:root {
+ color-scheme: light dark;
+ --bg: #f6f7f9;
+ --surface: #ffffff;
+ --surface-alt: #f0f2f5;
+ --border: #d9dde3;
+ --text: #1f2328;
+ --muted: #656d76;
+ --accent: #0969da;
+
+ /* Status palette: each has fg/bg/border so badges stay legible in both themes */
+ --success-fg: #116329; --success-bg: #dafbe1; --success-bd: #4ac26b;
+ --warning-fg: #7d4e00; --warning-bg: #fff8c5; --warning-bd: #d4a72c;
+ --failed-fg: #a40e26; --failed-bg: #ffebe9; --failed-bd: #ff8182;
+ --unknown-fg: #57606a; --unknown-bg: #eaeef2; --unknown-bd: #afb8c1;
+ --running-fg: #0550ae; --running-bg: #ddf4ff; --running-bd: #54aeff;
+
+ --radius: 8px;
+ --shadow: 0 1px 2px rgb(0 0 0 / 6%), 0 4px 12px rgb(0 0 0 / 4%);
+ font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
+ font-size: 15px;
+ line-height: 1.45;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --bg: #0d1117;
+ --surface: #161b22;
+ --surface-alt: #1c2128;
+ --border: #30363d;
+ --text: #e6edf3;
+ --muted: #8d96a0;
+ --accent: #4493f8;
+ --success-fg: #6fdd8b; --success-bg: #12261e; --success-bd: #2ea043;
+ --warning-fg: #f2cc60; --warning-bg: #272115; --warning-bd: #9e6a03;
+ --failed-fg: #ffa198; --failed-bg: #2d1214; --failed-bd: #da3633;
+ --unknown-fg: #9da7b3; --unknown-bg: #21262d; --unknown-bd: #484f58;
+ --running-fg: #79c0ff; --running-bg: #0c2d4b; --running-bd: #1f6feb;
+ --shadow: none;
+ }
+}
+
+*, *::before, *::after { box-sizing: border-box; }
+
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--text);
+}
+
+.muted { color: var(--muted); }
+
+.visually-hidden {
+ position: absolute !important;
+ width: 1px; height: 1px;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ white-space: nowrap;
+}
+
+/* ---------- Layout ---------- */
+.topbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ flex-wrap: wrap;
+ padding: 0.75rem 1.5rem;
+ background: var(--surface);
+ border-bottom: 1px solid var(--border);
+}
+.topbar__title { margin: 0; font-size: 1.25rem; }
+.topbar__meta { display: flex; align-items: center; gap: 0.75rem; font-size: 0.875rem; }
+
+.container {
+ max-width: 1400px;
+ margin: 0 auto;
+ padding: 1.25rem 1.5rem 3rem;
+}
+
+/* ---------- Controls ---------- */
+.btn {
+ font: inherit;
+ padding: 0.4rem 0.9rem;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--surface-alt);
+ color: var(--text);
+ cursor: pointer;
+}
+.btn:hover { border-color: var(--muted); }
+.btn:disabled { opacity: 0.6; cursor: progress; }
+.btn--icon { padding: 0.25rem 0.55rem; line-height: 1; }
+
+.input {
+ font: inherit;
+ width: min(100%, 360px);
+ padding: 0.45rem 0.75rem;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--surface);
+ color: var(--text);
+}
+
+:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+
+.toolbar { margin: 1rem 0; }
+
+/* ---------- Banner ---------- */
+.banner {
+ padding: 0.75rem 1rem;
+ border-radius: var(--radius);
+ margin-bottom: 1rem;
+ border: 1px solid;
+}
+.banner--error { background: var(--failed-bg); color: var(--failed-fg); border-color: var(--failed-bd); }
+
+/* ---------- Summary cards ---------- */
+.summary {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
+ gap: 0.75rem;
+}
+.summary-card {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.15rem;
+ padding: 0.9rem 1rem;
+ font: inherit;
+ text-align: left;
+ color: var(--text);
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-left-width: 4px;
+ border-radius: var(--radius);
+ box-shadow: var(--shadow);
+ cursor: pointer;
+}
+.summary-card[aria-pressed="true"] { outline: 2px solid var(--accent); outline-offset: -1px; }
+.summary-card__count { font-size: 1.75rem; font-weight: 600; font-variant-numeric: tabular-nums; }
+.summary-card__label { font-size: 0.85rem; color: var(--muted); }
+.summary-card--failed { border-left-color: var(--failed-bd); }
+.summary-card--warning { border-left-color: var(--warning-bd); }
+.summary-card--running { border-left-color: var(--running-bd); }
+.summary-card--success { border-left-color: var(--success-bd); }
+.summary-card--overdue { border-left-color: var(--unknown-bd); }
+
+/* ---------- Table ---------- */
+.table-wrap {
+ overflow-x: auto;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow);
+}
+.jobs { width: 100%; border-collapse: collapse; }
+.jobs th, .jobs td {
+ padding: 0.65rem 1rem;
+ text-align: left;
+ border-bottom: 1px solid var(--border);
+ white-space: nowrap;
+}
+.jobs tbody tr:last-child td { border-bottom: none; }
+.jobs tbody tr:hover { background: var(--surface-alt); }
+.jobs th { background: var(--surface-alt); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.03em; }
+
+.sort {
+ all: unset;
+ cursor: pointer;
+ color: var(--muted);
+ font-weight: 600;
+}
+.sort::after { content: " ↕"; opacity: 0.4; }
+th[aria-sort="ascending"] .sort::after { content: " ↑"; opacity: 1; }
+th[aria-sort="descending"] .sort::after { content: " ↓"; opacity: 1; }
+th[aria-sort="ascending"] .sort,
+th[aria-sort="descending"] .sort { color: var(--text); }
+
+.time { display: flex; flex-direction: column; }
+.time__rel { font-size: 0.8rem; color: var(--muted); }
+
+.empty { padding: 2rem; text-align: center; color: var(--muted); margin: 0; }
+
+/* ---------- Status badges ---------- */
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ padding: 0.2rem 0.65rem;
+ font: inherit;
+ font-size: 0.85rem;
+ font-weight: 600;
+ border: 1px solid;
+ border-radius: 999px;
+}
+button.badge { cursor: pointer; }
+button.badge:hover { filter: brightness(0.95); }
+/* Icon + text, not colour alone, so it's readable for colour-blind users */
+.badge__icon { font-size: 0.75rem; }
+.badge--success { color: var(--success-fg); background: var(--success-bg); border-color: var(--success-bd); }
+.badge--warning { color: var(--warning-fg); background: var(--warning-bg); border-color: var(--warning-bd); }
+.badge--failed { color: var(--failed-fg); background: var(--failed-bg); border-color: var(--failed-bd); }
+.badge--unknown { color: var(--unknown-fg); background: var(--unknown-bg); border-color: var(--unknown-bd); }
+.badge--running { color: var(--running-fg); background: var(--running-bg); border-color: var(--running-bd); }
+@media (prefers-reduced-motion: no-preference) {
+ .badge--running .badge__icon { animation: spin 2s linear infinite; }
+}
+@keyframes spin { to { transform: rotate(360deg); } }
+
+.tag-overdue {
+ margin-left: 0.5rem;
+ padding: 0.05rem 0.45rem;
+ font-size: 0.75rem;
+ font-weight: 600;
+ border-radius: 4px;
+ color: var(--failed-fg);
+ background: var(--failed-bg);
+}
+
+/* ---------- Modal ---------- */
+.modal {
+ width: min(720px, calc(100vw - 2rem));
+ max-height: calc(100vh - 4rem);
+ padding: 0;
+ color: var(--text);
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+}
+.modal::backdrop { background: rgb(0 0 0 / 45%); }
+.modal__header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 1rem;
+ padding: 1rem 1.25rem;
+ border-bottom: 1px solid var(--border);
+}
+.modal__title { margin: 0; font-size: 1.15rem; }
+.modal__header p { margin: 0.15rem 0 0; }
+.modal__body { padding: 1rem 1.25rem 1.25rem; }
+
+.detail-grid {
+ display: grid;
+ grid-template-columns: max-content 1fr;
+ gap: 0.4rem 1.25rem;
+ margin: 1rem 0;
+}
+.detail-grid dt { color: var(--muted); }
+.detail-grid dd { margin: 0; overflow-wrap: anywhere; }
+
+.detail-section-title { font-size: 0.85rem; text-transform: uppercase; color: var(--muted); margin: 1.25rem 0 0.5rem; }
+
+.message {
+ margin: 0;
+ padding: 0.75rem 1rem;
+ max-height: 320px;
+ overflow: auto;
+ white-space: pre-wrap; /* preserve backup-software line breaks */
+ overflow-wrap: anywhere;
+ font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
+ font-size: 0.85rem;
+ background: var(--surface-alt);
+ border: 1px solid var(--border);
+ border-left-width: 4px;
+ border-radius: 6px;
+}
+.message--failed { border-left-color: var(--failed-bd); }
+.message--warning { border-left-color: var(--warning-bd); }
+.message--success { border-left-color: var(--success-bd); }
+.message--running { border-left-color: var(--running-bd); }
+
+.detail-status { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
+
+.link {
+ all: unset;
+ color: var(--accent);
+ cursor: pointer;
+ text-decoration: underline;
+}
+
+/* ---------- Run history ---------- */
+.history-stats { margin: 0 0 0.6rem; font-size: 0.9rem; }
+
+/* One cell per day, oldest → today. Colour + tooltip; the runs table below is the accessible detail. */
+.day-strip {
+ display: grid;
+ grid-template-columns: repeat(30, 1fr);
+ gap: 3px;
+}
+.day {
+ height: 28px;
+ padding: 0;
+ border: 1px solid var(--border);
+ border-radius: 3px;
+ background: var(--surface-alt);
+ cursor: pointer;
+}
+.day:disabled { cursor: default; opacity: 0.6; }
+.day:not(:disabled):hover { filter: brightness(0.9); }
+.day[aria-pressed="true"] { outline: 2px solid var(--accent); outline-offset: 1px; }
+.day--success { background: var(--success-bd); border-color: var(--success-bd); }
+.day--warning { background: var(--warning-bd); border-color: var(--warning-bd); }
+.day--failed { background: var(--failed-bd); border-color: var(--failed-bd); }
+.day--running { background: var(--running-bd); border-color: var(--running-bd); }
+.day--unknown { background: var(--unknown-bd); border-color: var(--unknown-bd); }
+.day-strip__ticks {
+ display: flex;
+ justify-content: space-between;
+ margin-top: 0.25rem;
+ font-size: 0.75rem;
+}
+
+.history-filter { margin: 0.75rem 0 0; font-size: 0.85rem; }
+
+.runs-wrap {
+ margin-top: 0.75rem;
+ max-height: 300px;
+ overflow: auto;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+}
+.runs { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
+.runs th, .runs td {
+ padding: 0.4rem 0.75rem;
+ text-align: left;
+ white-space: nowrap;
+ border-bottom: 1px solid var(--border);
+}
+.runs th {
+ position: sticky;
+ top: 0;
+ background: var(--surface-alt);
+ color: var(--muted);
+ font-size: 0.75rem;
+ text-transform: uppercase;
+}
+.runs tbody tr:last-child td { border-bottom: none; }
+.runs tr.is-selected td { background: color-mix(in srgb, var(--accent) 16%, transparent); }
+.runs .badge { font-size: 0.75rem; padding: 0.1rem 0.5rem; }
\ No newline at end of file