Admins (Microsoft 365 sign-in names in BACKUP_DASH_ADMINS) get an Admin link to /admin.html, where they can create, list and revoke the API keys collectors use, instead of running manage.py on the server. Backend - /api/v1/admin/keys (list, create, revoke), admins only (403 otherwise); the user comes from the headers nginx sets after sign-in - Changes need an X-Requested-By header, which another site's page can't add, so a signed-in admin's browser can't be used to forge them - A new key is returned once; only its hash is stored. Names are unique for good, so revoked keys' history stays unambiguous - Keys record who created/revoked them and the company they last reported for (schema v2; databases migrate automatically on start) - /api/v1/me reports is_admin; manage.py shares the key code and records itself as creator/revoker Frontend - admin.html / admin.js: name validation shown after interaction (:user-invalid, aria-invalid kept in sync), the one-time key with a copy button (falls back to selecting it if clipboard access is refused), a key table flagging keys quiet for over 24 hours, and revoking confirmed in a <dialog>; results announced in a status region - Shared el() and signed-in helpers moved to dom.js / signed-in.js - Mock mode (?mock=1) includes sample keys Tested: 15 new backend tests (access, forged requests, names, duplicates, revoking, last-used company, v1 -> v2 upgrade); in the browser, every admin flow in mock mode and at phone width; end to end through the nginx config with the real API (a key created on the API works for ingest; revoking it makes ingest 401; non-admins see an explanation and no Admin link). Co-Authored-By: Claude Opus 5.5 <[email protected]>
170 lines
7.7 KiB
JavaScript
170 lines
7.7 KiB
JavaScript
// 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: '[email protected]',
|
||
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: '[email protected]',
|
||
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: '[email protected]' },
|
||
];
|
||
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 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: '[email protected]',
|
||
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 ??= '[email protected]';
|
||
return { key };
|
||
},
|
||
};
|