// 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)); // Sample API keys for the admin page: one healthy, one gone quiet, one never used, one revoked const KEYS = [ { id: 1, name: 'veeam-acme', created_at: iso(-40 * DAY), created_by: 'kyle@reliabilitytech.ca', last_used_at: iso(-12 * MIN), last_used_tenant: 'Acme Corp', revoked_at: null, revoked_by: null }, { id: 2, name: 'mars-bluewater-fs01', created_at: iso(-20 * DAY), created_by: 'manage.py (backupdash)', last_used_at: iso(-3 * DAY), last_used_tenant: 'Bluewater Dental', revoked_at: null, revoked_by: null }, { id: 3, name: 'wsb-northfield-dc01', created_at: iso(-2 * HR), created_by: 'kyle@reliabilitytech.ca', last_used_at: null, last_used_tenant: null, revoked_at: null, revoked_by: null }, { id: 4, name: 'veeam-old-server', created_at: iso(-90 * DAY), created_by: 'manage.py (backupdash)', last_used_at: iso(-31 * DAY), last_used_tenant: 'Acme Corp', revoked_at: iso(-30 * DAY), revoked_by: 'kyle@reliabilitytech.ca' }, ]; 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'); }, async getMe() { // No sign-in in mock mode, but show the admin pages so they can be tried out return { email: null, name: null, is_admin: true }; }, async listTenants() { await delay(150); const counts = new Map(); for (const j of JOBS) counts.set(j.company_name, (counts.get(j.company_name) ?? 0) + 1); return { tenants: [...counts].map(([name, jobs]) => ({ name, jobs })).sort((a, b) => a.name.localeCompare(b.name)) }; }, async tenantReport(tenant, days) { await delay(250); const jobs = JOBS.filter((j) => j.company_name.toLowerCase() === tenant.toLowerCase()); if (!jobs.length) throw new Error(`No company named '${tenant}'`); const since = now - days * DAY; return { tenant: jobs[0].company_name, days, since: new Date(since).toISOString(), until: new Date(now).toISOString(), generated_at: new Date(now).toISOString(), jobs: jobs.map(({ details, every_h, ...job }) => ({ ...job, // Earliest run on record, as the API reports it tracked_since: (RUNS.get(String(job.id)) ?? []).map((r) => r.started_at).sort()[0] ?? new Date(now).toISOString(), runs: (RUNS.get(String(job.id)) ?? []) .filter((r) => Date.parse(r.started_at) >= since) .map(({ id, details: _d, message, ...r }) => ({ ...r, message: ['warning', 'failed'].includes(r.status) ? message : null })) .reverse(), // oldest first, like the API })), }; }, // No server in mock mode: build the CSV here and link to it as a blob async tenantReportCsvUrl(tenant, days, tz) { const report = await this.tenantReport(tenant, days); const quoteCell = (v) => `"${String(v ?? '').replaceAll('"', '""')}"`; // Times stay in UTC here (the real API converts them to `tz`) const rows = [['Company', 'Job', 'Source', 'Started (UTC)', 'Finished (UTC)', 'Status', 'Message']]; for (const job of report.jobs) { for (const run of job.runs) { rows.push([report.tenant, job.job_name, job.source_system, run.started_at, run.finished_at ?? '', run.status, run.message ?? '']); } } const csv = rows.map((r) => r.map(quoteCell).join(',')).join('\r\n'); return URL.createObjectURL(new Blob(['\ufeff' + csv], { type: 'text/csv' })); }, async listKeys() { await delay(200); return { keys: [...KEYS].sort((a, b) => (!!a.revoked_at - !!b.revoked_at) || a.name.localeCompare(b.name)) }; }, async createKey(name) { await delay(300); if (KEYS.some((k) => k.name === name)) { throw new Error(`A key named '${name}' already exists (revoked keys keep their names)`); } const key = { id: KEYS.length + 1, name, created_at: new Date().toISOString(), created_by: 'you@example.com', last_used_at: null, last_used_tenant: null, revoked_at: null, revoked_by: null }; KEYS.push(key); const random = crypto.getRandomValues(new Uint8Array(32)); return { key, secret: `bdk_mock_${btoa(String.fromCharCode(...random)).replace(/[+/=]/g, '')}` }; }, async revokeKey(id) { await delay(200); const key = KEYS.find((k) => k.id === id); if (!key) throw new Error('Key not found'); key.revoked_at ??= new Date().toISOString(); key.revoked_by ??= 'you@example.com'; return { key }; }, };