From f7e029db8a8d078b39fe891ccbb6efb77e17e9a3 Mon Sep 17 00:00:00 2001 From: kyle Date: Mon, 5 Oct 2026 04:01:12 +0000 Subject: [PATCH] Upload files to "public/js" --- public/js/app.js | 527 ++++++++++++++++++++++++++++++++++++++++++++ public/js/config.js | 15 ++ public/js/format.js | 44 ++++ public/js/mock.js | 129 +++++++++++ 4 files changed, 715 insertions(+) create mode 100644 public/js/app.js create mode 100644 public/js/config.js create mode 100644 public/js/format.js create mode 100644 public/js/mock.js diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..5d49221 --- /dev/null +++ b/public/js/app.js @@ -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); \ No newline at end of file diff --git a/public/js/config.js b/public/js/config.js new file mode 100644 index 0000000..9a5f73a --- /dev/null +++ b/public/js/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/public/js/format.js b/public/js/format.js new file mode 100644 index 0000000..92459d0 --- /dev/null +++ b/public/js/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/public/js/mock.js b/public/js/mock.js new file mode 100644 index 0000000..77c53c6 --- /dev/null +++ b/public/js/mock.js @@ -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'); + }, +};