feat(backup): coverage diagnostic — what will the next backup miss?

Stage C of the three-stage backup-hardening plan (Stage A: inline
DB dump + fail-loud landed in 7fdf01a; Stage B: config-driven walker
in 302fc6b). Answers the "what would I lose if I clicked Run Backup
Now right now?" question that Stage B made possible to answer.
Backend:
  - new backupCoverageService.js: per-path coverage classification,
    drift detection (top-level subdirs not in backup_paths and not
    in the backups/tmp allow-list), DB-dump mode + staleness block
  - new GET /api/admin/system-health/backup-coverage route, same
    auth + settings.view permission as /backup-integrity
  - 7 integration scenarios pinning the classifier behaviour
Frontend:
  - new BackupCoverageCard with auto-fetch (cheap; no recursion)
  - new Coverage tab on BackupManagement next to Integrity
  - en + de i18n; other locales fall back to en keys until a native
    speaker reviews
Verification:
  - 26/26 backup integration tests pass (Stage A 5 + Stage B 7 +
    Stage C 7 + adminBackupIntegrity 4 + businessDocs 3)
  - frontend build clean
  - 4 pre-existing integration failures confirmed unrelated
This commit is contained in:
Luca
2026-05-29 22:20:32 +02:00
parent 302fc6b937
commit 03e6617f38
8 changed files with 1210 additions and 3 deletions
@@ -0,0 +1,242 @@
/**
* Integration test for GET /api/admin/system-health/backup-coverage.
*
* Pins the Stage C diagnostic that tells admins what the next
* "Run Backup Now" will include, skip, or silently miss.
*
* Test surface:
* 1. Empty / fresh install → default seed (7 paths), inline mode,
* no DB dump on file yet, no drift
* 2. Toggle `include_in_default=false` → coverage flips to
* 'skipped-by-toggle'
* 3. Feature_flag gating reflects the actual app_settings value
* (events/archived ⇄ backup_include_archived)
* 4. Drift detection: a top-level subdir on disk with no
* `backup_paths` row is flagged in `unconfiguredOnDisk`
* 5. Allow-list: `backups/` and `tmp/` are never flagged as drift
* 6. Scheduled-only mode + recent dump → `database.ok = true`
* 7. Scheduled-only mode + stale (>26h) dump → `database.ok = false`
* and `lastDumpStale = true`
*
* Same auth/permission pass-through strategy as
* adminBackupIntegrity.test.js — we exercise the route's logic,
* not the auth middleware.
*/
const fs = require('fs');
const path = require('path');
const express = require('express');
const request = require('supertest');
const { bootCrmDb } = require('./helpers/crmDb');
jest.mock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1 }; next(); },
customerAuth: (_req, _res, next) => next(),
galleryAuth: (_req, _res, next) => next(),
}));
jest.mock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.setTimeout(30000);
describe('GET /api/admin/system-health/backup-coverage', () => {
let db;
let cleanup;
let storagePath;
let app;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
storagePath = process.env.STORAGE_PATH;
const route = require('../../src/routes/adminSystemHealth');
app = express();
app.use(express.json());
app.use('/api/admin/system-health', route);
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
function mkdir(rel) {
fs.mkdirSync(path.join(storagePath, rel), { recursive: true });
}
function rmdir(rel) {
fs.rmSync(path.join(storagePath, rel), { recursive: true, force: true });
}
async function restoreDefaultPaths() {
await db('backup_paths').del();
const { DEFAULT_PATHS } = require('../../migrations/core/108_add_backup_paths');
await db('backup_paths').insert(DEFAULT_PATHS.map((row) => ({
...row,
created_at: new Date(),
updated_at: new Date(),
})));
}
beforeEach(async () => {
await restoreDefaultPaths();
await db('database_backup_runs').del().catch(() => {});
await db('app_settings').where('setting_type', 'backup').del().catch(() => {});
});
it('returns the canonical 7 paths + database block on a fresh install', async () => {
const res = await request(app).get('/api/admin/system-health/backup-coverage');
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('report');
const { report } = res.body;
expect(report.paths.map((p) => p.path)).toEqual([
'events/active',
'events/archived',
'thumbnails',
'previews',
'heroes',
'uploads',
'business-docs',
]);
// Default mode is inline — no inline_dump setting present means
// "inline is ON" (matches ensureDatabaseDumpForBackup semantics).
expect(report.database.mode).toBe('inline');
expect(report.database.ok).toBe(true);
expect(report.summary).toMatchObject({
configuredCount: 7,
tableMissingFallbackInUse: false,
});
});
it('flips a path to skipped-by-toggle when include_in_default=false', async () => {
await db('backup_paths').where('path', 'thumbnails').update({
include_in_default: false,
});
const res = await request(app).get('/api/admin/system-health/backup-coverage');
const thumbnails = res.body.report.paths.find((p) => p.path === 'thumbnails');
expect(thumbnails.coverage).toBe('skipped-by-toggle');
expect(thumbnails.includeInDefault).toBe(false);
});
it('feature_flag gating reflects app_settings (archived path off vs on)', async () => {
// backup_include_archived not set → archived skipped via flag
const off = await request(app).get('/api/admin/system-health/backup-coverage');
const archivedOff = off.body.report.paths.find((p) => p.path === 'events/archived');
expect(archivedOff.coverage).toBe('skipped-by-feature-flag');
expect(archivedOff.featureFlag).toBe('backup_include_archived');
expect(archivedOff.featureFlagValue).toBe(null); // unset
// Now set the flag — but path is missing on disk, so coverage
// resolves to 'missing-on-disk', proving the flag was honoured.
await db('app_settings').insert({
setting_key: 'backup_include_archived',
setting_value: JSON.stringify(true),
setting_type: 'backup',
}).onConflict('setting_key').merge();
const on = await request(app).get('/api/admin/system-health/backup-coverage');
const archivedOn = on.body.report.paths.find((p) => p.path === 'events/archived');
expect(archivedOn.featureFlagValue).toBe(true);
// No on-disk dir → 'missing-on-disk' (not 'skipped-by-feature-flag')
expect(['missing-on-disk', 'will-scan']).toContain(archivedOn.coverage);
});
it('detects unconfigured top-level subdirs as drift', async () => {
mkdir('events/active'); // configured
mkdir('plugin-store/cache'); // DRIFT
mkdir('shiny-new-feature/data'); // DRIFT
const res = await request(app).get('/api/admin/system-health/backup-coverage');
expect(res.body.report.drift.unconfiguredOnDisk).toEqual(expect.arrayContaining([
'plugin-store',
'shiny-new-feature',
]));
expect(res.body.report.drift.unconfiguredOnDisk).not.toContain('events');
rmdir('plugin-store');
rmdir('shiny-new-feature');
});
it('never flags backups/ or tmp/ as drift (allow-list)', async () => {
mkdir('backups');
mkdir('tmp');
const res = await request(app).get('/api/admin/system-health/backup-coverage');
expect(res.body.report.drift.unconfiguredOnDisk).not.toContain('backups');
expect(res.body.report.drift.unconfiguredOnDisk).not.toContain('tmp');
expect(res.body.report.drift.expectedNonBackupDirs).toEqual(
expect.arrayContaining(['backups', 'tmp']),
);
rmdir('backups');
rmdir('tmp');
});
it('scheduled-only mode + recent dump → database.ok=true, not stale', async () => {
await db('app_settings').insert({
setting_key: 'backup_database_inline_dump',
setting_value: JSON.stringify(false),
setting_type: 'backup',
}).onConflict('setting_key').merge();
const recentDump = path.join(storagePath, 'backups', 'recent.sql.gz');
fs.mkdirSync(path.dirname(recentDump), { recursive: true });
fs.writeFileSync(recentDump, 'pretend dump');
await db('database_backup_runs').insert({
started_at: new Date(),
completed_at: new Date(), // just now
status: 'completed',
backup_type: 'pg',
file_path: recentDump,
file_size_bytes: fs.statSync(recentDump).size,
destination_path: recentDump,
});
const res = await request(app).get('/api/admin/system-health/backup-coverage');
expect(res.body.report.database.mode).toBe('scheduled-only');
expect(res.body.report.database.inlineDumpExplicitlyDisabled).toBe(true);
expect(res.body.report.database.lastDumpStale).toBe(false);
expect(res.body.report.database.ok).toBe(true);
});
it('scheduled-only mode + stale dump → database.ok=false, lastDumpStale=true', async () => {
await db('app_settings').insert({
setting_key: 'backup_database_inline_dump',
setting_value: JSON.stringify(false),
setting_type: 'backup',
}).onConflict('setting_key').merge();
const oldDump = path.join(storagePath, 'backups', 'old.sql.gz');
fs.mkdirSync(path.dirname(oldDump), { recursive: true });
fs.writeFileSync(oldDump, 'pretend old dump');
// 48 hours ago — well past the 26h staleness threshold. ISO
// string instead of a Date object because knex-sqlite's datetime
// serialisation has a quirk where some Date instances coerce to
// '[object Object]' on insert (the test 6 "recent dump" case
// passes only because `new Date()` happens to round-trip safely;
// arithmetic Dates don't).
const stale = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString();
await db('database_backup_runs').insert({
started_at: stale,
completed_at: stale,
status: 'completed',
backup_type: 'pg',
file_path: oldDump,
file_size_bytes: fs.statSync(oldDump).size,
destination_path: oldDump,
});
const res = await request(app).get('/api/admin/system-health/backup-coverage');
expect(res.body.report.database.lastDumpStale).toBe(true);
expect(res.body.report.database.ok).toBe(false);
// Top-level summary reflects the failed DB check.
expect(res.body.report.summary.databaseOk).toBe(false);
expect(res.body.report.summary.overallOk).toBe(false);
});
});
+23
View File
@@ -22,6 +22,7 @@ const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions'); const { requirePermission } = require('../middleware/permissions');
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers'); const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const { verifyDocumentArtefacts } = require('../services/backupIntegrityService'); const { verifyDocumentArtefacts } = require('../services/backupIntegrityService');
const { getCoverageReport } = require('../services/backupCoverageService');
const router = express.Router(); const router = express.Router();
@@ -61,4 +62,26 @@ router.get(
}), }),
); );
/**
* GET /api/admin/system-health/backup-coverage
*
* Stage C of the backup-hardening plan. Returns the data-driven
* coverage report — what the next "Run Backup Now" will include /
* skip / silently miss, plus the database-dump status block.
*
* Read-only, on-demand. No scope parameter — the report is cheap
* (only top-level directory listing under STORAGE_PATH, no recursion).
*
* See backupCoverageService.js for the full rationale and the
* coverage-classification rules.
*/
router.get(
'/backup-coverage',
requirePermission('settings.view'),
handleAsync(async (req, res) => {
const report = await getCoverageReport();
return successResponse(res, { report });
}),
);
module.exports = router; module.exports = router;
@@ -0,0 +1,351 @@
/**
* Backup-coverage diagnostic — Stage C of the backup-hardening plan.
*
* **Why this is a separate service**
*
* Stage A (inline DB dump + fail-loud) and Stage B (config-driven
* walker via `backup_paths`) close the data-loss footgun, but they
* don't tell an admin *what* the next backup will actually cover.
* That's a separate question — and a particularly important one,
* because the whole reason Stage B exists is that the walker's
* subdirectory list used to silently fall behind reality every time
* a new feature dropped artefacts under STORAGE_PATH.
*
* This service answers two questions:
*
* 1. For every row in `backup_paths`, what will the next backup
* do with it? (scan / skip-via-feature-flag / skip-via-toggle /
* missing-on-disk)
* 2. What subdirectories EXIST under STORAGE_PATH but have NO row
* in `backup_paths` — i.e. drift the admin should know about
* before they lose data on a restore?
*
* Plus a top-level database-dump status block: are we configured
* for inline dump (default), or relying on the scheduled dump?
* When was the last successful dump? Is it stale?
*
* **What this service does NOT do**
*
* - Does not run the backup
* - Does not write anything (no DB mutations, no fs touches)
* - Does not auto-recover drift (it's a diagnostic — admins decide
* whether to add a `backup_paths` row, delete the orphan dir, etc.)
* - Does not walk file contents — only top-level directory entries
* under STORAGE_PATH are inspected (cheap; no recursion through
* potentially-millions of photos)
*
* Read-only. Returns a JSON report — same shape as
* backupIntegrityService.verifyDocumentArtefacts.
*/
const fs = require('fs').promises;
const path = require('path');
const { db } = require('../database/db');
const logger = require('../utils/logger');
const backupService = require('./backupService');
const STORAGE_ROOT = () => process.env.STORAGE_PATH || path.join(process.cwd(), 'storage');
/**
* Top-level subdirectories we expect to find under STORAGE_PATH but
* which are intentionally NOT in `backup_paths` — they're generated
* caches / runtime artefacts that the backup is supposed to skip.
* Listing them here keeps the drift detector from flagging them.
*
* `backups` — the destination directory the backup writer itself
* creates, plus the `database_backup_runs` dump files.
* Including it in the walker would create a recursive
* "backup of backups" feedback loop.
*
* `tmp` — short-lived scratch space (e.g. PDF render staging,
* S3 multipart uploads). Re-created on demand, never
* holds the only copy of anything.
*/
const EXPECTED_NON_BACKUP_DIRS = new Set([
'backups',
'tmp',
]);
/**
* How stale a database dump can be before we flag it. 26 hours so a
* daily scheduled dump is still considered "fresh" if it ran a few
* hours late.
*/
const DB_DUMP_STALE_AFTER_MS = 26 * 60 * 60 * 1000;
function parseSettingValue(raw) {
if (raw === null || raw === undefined) return null;
if (typeof raw !== 'string') return raw;
try { return JSON.parse(raw); } catch (_) {
if (raw === 'true') return true;
if (raw === 'false') return false;
const n = Number(raw);
return Number.isFinite(n) ? n : raw;
}
}
async function readBackupConfig() {
try {
const rows = await db('app_settings')
.where('setting_type', 'backup')
.select('setting_key', 'setting_value');
const cfg = {};
for (const row of rows) {
cfg[row.setting_key] = parseSettingValue(row.setting_value);
}
return cfg;
} catch (err) {
logger.warn(`backup-coverage: could not read backup config — ${err.message}`);
return {};
}
}
async function listConfiguredPaths() {
try {
if (!(await db.schema.hasTable('backup_paths'))) return null;
return await db('backup_paths')
.orderBy('display_order', 'asc')
.select('path', 'include_in_default', 'feature_flag', 'display_order', 'description');
} catch (err) {
logger.warn(`backup-coverage: could not read backup_paths — ${err.message}`);
return null;
}
}
async function listTopLevelStorageDirs() {
const root = STORAGE_ROOT();
try {
const entries = await fs.readdir(root, { withFileTypes: true });
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
} catch (err) {
if (err.code === 'ENOENT') return [];
logger.warn(`backup-coverage: could not read STORAGE_PATH (${root}) — ${err.message}`);
return [];
}
}
async function statPath(absPath) {
try {
const st = await fs.stat(absPath);
return { exists: true, isDir: st.isDirectory() };
} catch (err) {
if (err.code === 'ENOENT') return { exists: false, isDir: false };
throw err;
}
}
/**
* Build the database-dump status block. Tells the admin whether
* "Run Backup Now" will inline-dump (default) or rely on the
* scheduled-dump path, plus how fresh the most recent dump is.
*/
async function buildDatabaseStatus(config) {
// normalizeBoolean(undefined) === false, so we have to gate on
// explicit-false the same way ensureDatabaseDumpForBackup does.
const inlineExplicitlyOff = config.backup_database_inline_dump !== undefined
&& config.backup_database_inline_dump !== null
&& config.backup_database_inline_dump === false;
const mode = inlineExplicitlyOff ? 'scheduled-only' : 'inline';
let recent = null;
try {
if (await db.schema.hasTable('database_backup_runs')) {
recent = await db('database_backup_runs')
.where('status', 'completed')
.orderBy('completed_at', 'desc')
.first();
}
} catch (err) {
logger.warn(`backup-coverage: could not read database_backup_runs — ${err.message}`);
}
const status = {
mode,
inlineDumpExplicitlyDisabled: inlineExplicitlyOff,
lastDumpAt: recent ? recent.completed_at : null,
lastDumpType: recent ? recent.backup_type : null,
lastDumpSizeBytes: recent ? Number(recent.file_size_bytes || 0) : 0,
lastDumpFilePath: recent ? recent.file_path : null,
lastDumpAgeMs: null,
lastDumpStale: null,
ok: null,
};
if (recent && recent.completed_at) {
const completedAt = recent.completed_at instanceof Date
? recent.completed_at
: new Date(recent.completed_at);
status.lastDumpAgeMs = Date.now() - completedAt.getTime();
status.lastDumpStale = status.lastDumpAgeMs > DB_DUMP_STALE_AFTER_MS;
}
// ok semantics:
// - inline mode: always ok=true (next backup will produce a fresh
// dump on demand, staleness is irrelevant)
// - scheduled-only: ok=true iff a recent non-stale dump exists,
// because the file-backup guard will fail-loud otherwise
if (mode === 'inline') {
status.ok = true;
} else {
status.ok = Boolean(recent && recent.file_path && status.lastDumpStale === false);
}
return status;
}
/**
* Per-path coverage:
* - configured + include_in_default + (no feature_flag OR flag truthy) → 'will-scan'
* - configured + include_in_default + flag falsey → 'skipped-by-feature-flag'
* - configured + include_in_default = false → 'skipped-by-toggle'
* - configured but missing on disk → 'missing-on-disk'
*
* Returns one entry per `backup_paths` row.
*/
async function buildConfiguredPathReport(configuredRows, config) {
const root = STORAGE_ROOT();
const result = [];
for (const row of configuredRows) {
const absPath = path.join(root, row.path);
const stat = await statPath(absPath);
const includedInDefault = Boolean(row.include_in_default);
let featureFlagValue = null;
if (row.feature_flag) {
const v = config[row.feature_flag];
featureFlagValue = v === undefined ? null : Boolean(v);
}
let coverage;
if (!includedInDefault) {
coverage = 'skipped-by-toggle';
} else if (row.feature_flag && featureFlagValue !== true) {
// null (unset) and explicit false both gate the path off — matches
// the walker's normalizeBoolean semantics
coverage = 'skipped-by-feature-flag';
} else if (!stat.exists) {
coverage = 'missing-on-disk';
} else {
coverage = 'will-scan';
}
result.push({
path: row.path,
includeInDefault: includedInDefault,
featureFlag: row.feature_flag || null,
featureFlagValue,
displayOrder: row.display_order,
description: row.description || null,
existsOnDisk: stat.exists,
coverage,
});
}
return result;
}
/**
* Drift detection: top-level subdirs under STORAGE_PATH that are not
* in `backup_paths` AND not in the `EXPECTED_NON_BACKUP_DIRS` allow-list.
*
* These are the directories that will be missed by "Run Backup Now"
* — either intentionally (a new feature drops cache files there and
* the admin doesn't want them backed up — they should add them to the
* allow-list) or accidentally (a feature shipped without a matching
* `backup_paths` row — the data-loss footgun this whole effort is
* designed to catch).
*/
function detectDrift(diskDirs, configuredPaths) {
// configured paths can be nested ('events/active'); we only diff the
// top-level segment ('events') because that's the granularity admins
// see in the storage tree. A path like 'events/active' implies the
// 'events' top-level is "known to the backup config".
const configuredTopLevels = new Set(
configuredPaths.map((p) => p.path.split('/')[0]),
);
return diskDirs
.filter((d) => !configuredTopLevels.has(d))
.filter((d) => !EXPECTED_NON_BACKUP_DIRS.has(d))
.sort();
}
/**
* Public entry point.
*
* @returns {Promise<{
* database: object,
* paths: Array<object>,
* drift: { unconfiguredOnDisk: string[], expectedNonBackupDirs: string[] },
* summary: object,
* generatedAt: string,
* }>}
*/
async function getCoverageReport() {
const config = await readBackupConfig();
const configuredRows = await listConfiguredPaths();
const diskDirs = await listTopLevelStorageDirs();
// Fallback when the table doesn't exist yet (migration 108 hasn't
// run for some reason). Mirrors the walker's LEGACY_BACKUP_PATHS
// contract — every other layer of this system uses the same
// belt-and-suspenders fallback.
const fallback = configuredRows === null;
const effectiveRows = configuredRows || [
{ path: 'events/active', include_in_default: true, feature_flag: null, display_order: 10, description: 'Legacy fallback (backup_paths missing)' },
{ path: 'events/archived', include_in_default: true, feature_flag: 'backup_include_archived', display_order: 20, description: 'Legacy fallback (backup_paths missing)' },
{ path: 'thumbnails', include_in_default: true, feature_flag: null, display_order: 30, description: 'Legacy fallback (backup_paths missing)' },
{ path: 'previews', include_in_default: true, feature_flag: null, display_order: 40, description: 'Legacy fallback (backup_paths missing)' },
{ path: 'heroes', include_in_default: true, feature_flag: null, display_order: 50, description: 'Legacy fallback (backup_paths missing)' },
{ path: 'uploads', include_in_default: true, feature_flag: null, display_order: 60, description: 'Legacy fallback (backup_paths missing)' },
{ path: 'business-docs', include_in_default: true, feature_flag: null, display_order: 70, description: 'Legacy fallback (backup_paths missing)' },
];
const [database, paths] = await Promise.all([
buildDatabaseStatus(config),
buildConfiguredPathReport(effectiveRows, config),
]);
const unconfiguredOnDisk = detectDrift(diskDirs, effectiveRows);
const summary = {
configuredCount: effectiveRows.length,
willScanCount: paths.filter((p) => p.coverage === 'will-scan').length,
skippedByToggleCount: paths.filter((p) => p.coverage === 'skipped-by-toggle').length,
skippedByFeatureFlagCount: paths.filter((p) => p.coverage === 'skipped-by-feature-flag').length,
missingOnDiskCount: paths.filter((p) => p.coverage === 'missing-on-disk').length,
driftCount: unconfiguredOnDisk.length,
tableMissingFallbackInUse: fallback,
databaseOk: database.ok,
// overall: green only when DB is ok AND there's at least one path
// that will actually be scanned AND no drift was found
overallOk: Boolean(
database.ok
&& paths.some((p) => p.coverage === 'will-scan')
&& unconfiguredOnDisk.length === 0,
),
};
return {
database,
paths,
drift: {
unconfiguredOnDisk,
expectedNonBackupDirs: Array.from(EXPECTED_NON_BACKUP_DIRS).sort(),
},
summary,
generatedAt: new Date().toISOString(),
};
}
module.exports = {
getCoverageReport,
// Exported for test introspection — the route doesn't use these.
EXPECTED_NON_BACKUP_DIRS,
DB_DUMP_STALE_AFTER_MS,
};
// Silence unused import lint warning — backupService is required so
// the module-graph cache primes (some tests jest.mock it before
// requiring this service).
void backupService;
@@ -0,0 +1,430 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
ShieldCheck,
ShieldAlert,
Database,
FolderTree,
AlertTriangle,
CheckCircle2,
XCircle,
EyeOff,
Clock,
RefreshCw,
Loader2,
} from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { format } from 'date-fns';
import { Card, Button } from '../common';
import {
adminService,
BackupCoverageReport,
BackupPathCoverage,
} from '../../services/admin.service';
/**
* BackupCoverageCard — Stage C of the backup-hardening plan.
*
* Tells the admin what the next "Run Backup Now" will actually do:
*
* - Database: inline-dump or scheduled, last dump age, staleness
* - Configured paths: per-row coverage (will-scan / skipped by
* toggle / skipped by feature flag / missing on disk)
* - Drift: top-level subdirs under STORAGE_PATH that have no
* `backup_paths` row (the "feature shipped without a backup row"
* footgun this whole effort is designed to catch)
*
* Auto-fetches on mount — unlike the integrity verifier, this is
* a cheap query (no recursion) so admins should always see the
* current state when they open the tab.
*/
export const BackupCoverageCard: React.FC = () => {
const { t } = useTranslation();
const { data, isLoading, isError, error, refetch, isFetching } = useQuery({
queryKey: ['backup-coverage'],
queryFn: () => adminService.getBackupCoverage(),
// The report changes only when (a) backup_paths is edited or
// (b) a new scheduled dump completes. Stale time of 30s keeps
// the UI snappy without hammering the endpoint.
staleTime: 30_000,
});
return (
<Card className="p-6">
<Header report={data} loading={isLoading} onRefresh={() => refetch()} refreshing={isFetching} />
{isError && (
<ErrorBanner message={(error as Error)?.message ?? 'unknown error'} />
)}
{data && (
<>
{data.summary.tableMissingFallbackInUse && (
<FallbackWarning />
)}
<SectionGrid>
<DatabaseStatusCard database={data.database} />
<SummaryCard summary={data.summary} />
</SectionGrid>
<PathsTable paths={data.paths} />
<DriftSection drift={data.drift} />
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-4">
{t('backup.coverage.generatedAt', 'Coverage generated: {{when}}', {
when: format(new Date(data.generatedAt), 'yyyy-MM-dd HH:mm:ss'),
})}
</p>
</>
)}
</Card>
);
};
const Header: React.FC<{
report: BackupCoverageReport | undefined;
loading: boolean;
onRefresh: () => void;
refreshing: boolean;
}> = ({ report, loading, onRefresh, refreshing }) => {
const { t } = useTranslation();
const healthy = report?.summary.overallOk;
return (
<div className="flex items-start justify-between mb-4">
<div>
<div className="flex items-center gap-2 mb-1">
{loading || refreshing ? (
<Loader2 className="w-5 h-5 text-neutral-400 animate-spin" />
) : healthy ? (
<ShieldCheck className="w-5 h-5 text-green-600 dark:text-green-400" />
) : report ? (
<ShieldAlert className="w-5 h-5 text-amber-600 dark:text-amber-400" />
) : (
<ShieldCheck className="w-5 h-5 text-neutral-400" />
)}
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('backup.coverage.title', 'Backup coverage')}
</h3>
</div>
<p className="text-sm text-neutral-600 dark:text-neutral-400 max-w-2xl">
{t(
'backup.coverage.description',
'Shows what the next backup will include, skip, or silently miss. The database block confirms the dump strategy. The "drift" section flags subdirectories that exist on disk but are not in the backup configuration — usually a sign that a new feature shipped without a matching backup_paths row.',
)}
</p>
</div>
<Button
variant="ghost"
onClick={onRefresh}
disabled={loading || refreshing}
leftIcon={
refreshing
? <Loader2 className="w-4 h-4 animate-spin" />
: <RefreshCw className="w-4 h-4" />
}
>
{t('backup.coverage.refresh', 'Refresh')}
</Button>
</div>
);
};
const ErrorBanner: React.FC<{ message: string }> = ({ message }) => {
const { t } = useTranslation();
return (
<div className="mb-4 p-3 rounded-lg bg-red-50 dark:bg-red-900/30 text-sm text-red-700 dark:text-red-300">
{t('backup.coverage.error', 'Could not load coverage report: {{message}}', { message })}
</div>
);
};
const FallbackWarning: React.FC = () => {
const { t } = useTranslation();
return (
<div className="mb-4 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/30 text-sm text-amber-800 dark:text-amber-200 flex items-start gap-2">
<AlertTriangle className="w-4 h-4 flex-shrink-0 mt-0.5" />
<span>
{t(
'backup.coverage.fallbackInUse',
'The backup_paths table is missing. The walker is using its legacy hard-coded fallback. Migration 108 may not have run — check server logs and re-run migrations.',
)}
</span>
</div>
);
};
const SectionGrid: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">{children}</div>
);
const DatabaseStatusCard: React.FC<{
database: BackupCoverageReport['database'];
}> = ({ database }) => {
const { t } = useTranslation();
const isInline = database.mode === 'inline';
const tone: Tone = database.ok ? 'green' : 'red';
const dumpAge = database.lastDumpAgeMs !== null
? formatAge(database.lastDumpAgeMs)
: null;
return (
<div className={`rounded-lg p-4 ${TONE_BG[tone]}`}>
<div className="flex items-center gap-2 mb-2">
<Database className="w-4 h-4" />
<h4 className="font-semibold text-sm uppercase tracking-wide">
{t('backup.coverage.database.title', 'Database')}
</h4>
{database.ok ? (
<CheckCircle2 className="w-4 h-4 ml-auto" />
) : (
<XCircle className="w-4 h-4 ml-auto" />
)}
</div>
<dl className="space-y-1 text-sm">
<Row
label={t('backup.coverage.database.mode', 'Mode')}
value={isInline
? t('backup.coverage.database.modeInline', 'Inline dump on every backup')
: t('backup.coverage.database.modeScheduled', 'Scheduled-only (inline opted out)')}
/>
{database.lastDumpAt ? (
<>
<Row
label={t('backup.coverage.database.lastDump', 'Last dump')}
value={`${format(new Date(database.lastDumpAt), 'yyyy-MM-dd HH:mm')}${
dumpAge ? ` (${dumpAge})` : ''
}`}
/>
<Row
label={t('backup.coverage.database.lastDumpSize', 'Size')}
value={formatBytes(database.lastDumpSizeBytes)}
/>
</>
) : (
<Row
label={t('backup.coverage.database.lastDump', 'Last dump')}
value={t('backup.coverage.database.noDump', 'No dump on file yet')}
/>
)}
{database.lastDumpStale && (
<Row
label={t('backup.coverage.database.staleLabel', 'Status')}
value={t('backup.coverage.database.stale', 'Stale — older than 26h')}
icon={<Clock className="w-3.5 h-3.5" />}
/>
)}
</dl>
</div>
);
};
const SummaryCard: React.FC<{
summary: BackupCoverageReport['summary'];
}> = ({ summary }) => {
const { t } = useTranslation();
const tone: Tone = summary.overallOk
? 'green'
: summary.driftCount > 0 || !summary.databaseOk
? 'amber'
: 'neutral';
return (
<div className={`rounded-lg p-4 ${TONE_BG[tone]}`}>
<div className="flex items-center gap-2 mb-2">
<FolderTree className="w-4 h-4" />
<h4 className="font-semibold text-sm uppercase tracking-wide">
{t('backup.coverage.summary.title', 'Summary')}
</h4>
</div>
<dl className="space-y-1 text-sm">
<Row
label={t('backup.coverage.summary.willScan', 'Will scan')}
value={`${summary.willScanCount} / ${summary.configuredCount}`}
/>
{summary.skippedByToggleCount > 0 && (
<Row
label={t('backup.coverage.summary.skippedByToggle', 'Skipped (toggle off)')}
value={String(summary.skippedByToggleCount)}
/>
)}
{summary.skippedByFeatureFlagCount > 0 && (
<Row
label={t('backup.coverage.summary.skippedByFlag', 'Skipped (feature flag)')}
value={String(summary.skippedByFeatureFlagCount)}
/>
)}
{summary.missingOnDiskCount > 0 && (
<Row
label={t('backup.coverage.summary.missingOnDisk', 'Missing on disk')}
value={String(summary.missingOnDiskCount)}
/>
)}
<Row
label={t('backup.coverage.summary.drift', 'Unconfigured on disk (drift)')}
value={String(summary.driftCount)}
/>
</dl>
</div>
);
};
const PathsTable: React.FC<{ paths: BackupCoverageReport['paths'] }> = ({ paths }) => {
const { t } = useTranslation();
return (
<div className="border border-neutral-200 dark:border-neutral-700 rounded-lg overflow-hidden">
<div className="px-3 py-2 bg-neutral-50 dark:bg-neutral-800/50 border-b border-neutral-200 dark:border-neutral-700">
<h4 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">
{t('backup.coverage.paths.heading', 'Configured paths')}
</h4>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-neutral-50 dark:bg-neutral-800/30">
<tr className="text-left text-xs uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
<th className="px-3 py-2">{t('backup.coverage.paths.path', 'Path')}</th>
<th className="px-3 py-2">{t('backup.coverage.paths.coverage', 'Coverage')}</th>
<th className="px-3 py-2">{t('backup.coverage.paths.featureFlag', 'Feature flag')}</th>
<th className="px-3 py-2">{t('backup.coverage.paths.description', 'Description')}</th>
</tr>
</thead>
<tbody>
{paths.map((p) => (
<tr
key={p.path}
className="border-t border-neutral-200 dark:border-neutral-700"
>
<td className="px-3 py-2 font-mono text-xs text-neutral-700 dark:text-neutral-300">
{p.path}
</td>
<td className="px-3 py-2">
<CoverageBadge coverage={p.coverage} />
</td>
<td className="px-3 py-2 text-xs text-neutral-600 dark:text-neutral-400">
{p.featureFlag
? `${p.featureFlag} = ${p.featureFlagValue === null ? '∅' : String(p.featureFlagValue)}`
: '—'}
</td>
<td className="px-3 py-2 text-xs text-neutral-600 dark:text-neutral-400">
{p.description ?? '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
const DriftSection: React.FC<{ drift: BackupCoverageReport['drift'] }> = ({ drift }) => {
const { t } = useTranslation();
if (drift.unconfiguredOnDisk.length === 0) {
return (
<div className="mt-4 p-3 rounded-lg bg-green-50 dark:bg-green-900/30 text-sm text-green-700 dark:text-green-300 flex items-center gap-2">
<CheckCircle2 className="w-4 h-4" />
{t(
'backup.coverage.drift.none',
'No drift detected — every top-level subdirectory under STORAGE_PATH is either in backup_paths or in the expected non-backup allow-list.',
)}
</div>
);
}
return (
<div className="mt-4 border border-amber-300 dark:border-amber-700 rounded-lg overflow-hidden">
<div className="px-3 py-2 bg-amber-50 dark:bg-amber-900/30 border-b border-amber-300 dark:border-amber-700">
<div className="flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-amber-700 dark:text-amber-300" />
<h4 className="text-sm font-semibold text-amber-800 dark:text-amber-200">
{t('backup.coverage.drift.heading', 'Drift detected: subdirectories not covered by any backup_paths row')}
</h4>
</div>
<p className="text-xs text-amber-700 dark:text-amber-300 mt-1">
{t(
'backup.coverage.drift.caption',
'These directories exist on disk but the walker will skip them. Either add a backup_paths row, move the files into a covered location, or — if they are runtime caches — confirm they are safe to exclude.',
)}
</p>
</div>
<ul className="divide-y divide-amber-200 dark:divide-amber-800">
{drift.unconfiguredOnDisk.map((d) => (
<li
key={d}
className="px-3 py-2 font-mono text-xs text-amber-900 dark:text-amber-100 flex items-center gap-2"
>
<EyeOff className="w-3.5 h-3.5" />
{d}
</li>
))}
</ul>
</div>
);
};
const CoverageBadge: React.FC<{ coverage: BackupPathCoverage }> = ({ coverage }) => {
const { t } = useTranslation();
const map: Record<BackupPathCoverage, { tone: Tone; label: string }> = {
'will-scan': {
tone: 'green',
label: t('backup.coverage.coverage.willScan', 'Will scan'),
},
'skipped-by-toggle': {
tone: 'neutral',
label: t('backup.coverage.coverage.skippedByToggle', 'Off'),
},
'skipped-by-feature-flag': {
tone: 'neutral',
label: t('backup.coverage.coverage.skippedByFlag', 'Gated off'),
},
'missing-on-disk': {
tone: 'amber',
label: t('backup.coverage.coverage.missingOnDisk', 'Missing on disk'),
},
};
const { tone, label } = map[coverage];
return (
<span className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${TONE_BG[tone]}`}>
{label}
</span>
);
};
const Row: React.FC<{ label: string; value: string; icon?: React.ReactNode }> = ({
label, value, icon,
}) => (
<div className="flex justify-between items-center gap-3">
<dt className="text-xs uppercase tracking-wide opacity-80 flex items-center gap-1">
{icon}
{label}
</dt>
<dd className="text-sm font-medium text-right">{value}</dd>
</div>
);
type Tone = 'neutral' | 'green' | 'amber' | 'red';
const TONE_BG: Record<Tone, string> = {
neutral: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-200',
green: 'bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300',
amber: 'bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300',
red: 'bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300',
};
function formatBytes(bytes: number): string {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
return `${(bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
function formatAge(ms: number): string {
const sec = Math.floor(ms / 1000);
if (sec < 60) return `${sec}s ago`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 48) return `${hr}h ago`;
const day = Math.floor(hr / 24);
return `${day}d ago`;
}
+47 -1
View File
@@ -231,7 +231,8 @@
"configuration": "Konfiguration", "configuration": "Konfiguration",
"history": "Backup-Verlauf", "history": "Backup-Verlauf",
"restore": "Wiederherstellung", "restore": "Wiederherstellung",
"integrity": "Integrität" "integrity": "Integrität",
"coverage": "Abdeckung"
}, },
"integrity": { "integrity": {
"title": "Dokumentintegrität", "title": "Dokumentintegrität",
@@ -264,6 +265,51 @@
"detail": "Detail" "detail": "Detail"
} }
}, },
"coverage": {
"title": "Backup-Abdeckung",
"description": "Zeigt, was das nächste Backup einschließt, überspringt oder stillschweigend verfehlt. Der Datenbank-Block bestätigt die Dump-Strategie. Der \"Drift\"-Abschnitt markiert Unterverzeichnisse, die auf der Festplatte existieren, aber nicht in der Backup-Konfiguration stehen — meist ein Hinweis darauf, dass eine neue Funktion ohne passende backup_paths-Zeile ausgeliefert wurde.",
"refresh": "Aktualisieren",
"error": "Abdeckungs-Bericht konnte nicht geladen werden: {{message}}",
"generatedAt": "Abdeckung erstellt: {{when}}",
"fallbackInUse": "Die Tabelle backup_paths fehlt. Der Walker nutzt seine fest verdrahtete Legacy-Fallback-Liste. Migration 108 wurde möglicherweise nicht ausgeführt — prüfen Sie die Server-Logs und führen Sie die Migrationen erneut aus.",
"database": {
"title": "Datenbank",
"mode": "Modus",
"modeInline": "Inline-Dump bei jedem Backup",
"modeScheduled": "Nur geplant (Inline abgewählt)",
"lastDump": "Letzter Dump",
"lastDumpSize": "Größe",
"noDump": "Noch kein Dump vorhanden",
"staleLabel": "Status",
"stale": "Veraltet — älter als 26 Std."
},
"summary": {
"title": "Zusammenfassung",
"willScan": "Wird gescannt",
"skippedByToggle": "Übersprungen (Schalter aus)",
"skippedByFlag": "Übersprungen (Feature-Flag)",
"missingOnDisk": "Auf Festplatte fehlend",
"drift": "Nicht konfiguriert auf Festplatte (Drift)"
},
"paths": {
"heading": "Konfigurierte Pfade",
"path": "Pfad",
"coverage": "Abdeckung",
"featureFlag": "Feature-Flag",
"description": "Beschreibung"
},
"coverage": {
"willScan": "Wird gescannt",
"skippedByToggle": "Aus",
"skippedByFlag": "Per Flag aus",
"missingOnDisk": "Auf Festplatte fehlend"
},
"drift": {
"heading": "Drift erkannt: Unterverzeichnisse ohne backup_paths-Zeile",
"caption": "Diese Verzeichnisse existieren auf der Festplatte, werden vom Walker aber übersprungen. Entweder eine backup_paths-Zeile hinzufügen, die Dateien in ein abgedecktes Verzeichnis verschieben, oder — wenn es sich um Laufzeit-Caches handelt — bestätigen, dass der Ausschluss sicher ist.",
"none": "Kein Drift erkannt — jedes Top-Level-Unterverzeichnis unter STORAGE_PATH steht entweder in backup_paths oder in der erwarteten Nicht-Backup-Allow-List."
}
},
"status": { "status": {
"inProgress": "Backup läuft...", "inProgress": "Backup läuft...",
"lastBackup": "Letztes Backup", "lastBackup": "Letztes Backup",
+47 -1
View File
@@ -2122,7 +2122,8 @@
"configuration": "Configuration", "configuration": "Configuration",
"history": "Backup History", "history": "Backup History",
"restore": "Restore", "restore": "Restore",
"integrity": "Integrity" "integrity": "Integrity",
"coverage": "Coverage"
}, },
"integrity": { "integrity": {
"title": "Document integrity", "title": "Document integrity",
@@ -2155,6 +2156,51 @@
"detail": "Detail" "detail": "Detail"
} }
}, },
"coverage": {
"title": "Backup coverage",
"description": "Shows what the next backup will include, skip, or silently miss. The database block confirms the dump strategy. The \"drift\" section flags subdirectories that exist on disk but are not in the backup configuration — usually a sign that a new feature shipped without a matching backup_paths row.",
"refresh": "Refresh",
"error": "Could not load coverage report: {{message}}",
"generatedAt": "Coverage generated: {{when}}",
"fallbackInUse": "The backup_paths table is missing. The walker is using its legacy hard-coded fallback. Migration 108 may not have run — check server logs and re-run migrations.",
"database": {
"title": "Database",
"mode": "Mode",
"modeInline": "Inline dump on every backup",
"modeScheduled": "Scheduled-only (inline opted out)",
"lastDump": "Last dump",
"lastDumpSize": "Size",
"noDump": "No dump on file yet",
"staleLabel": "Status",
"stale": "Stale — older than 26h"
},
"summary": {
"title": "Summary",
"willScan": "Will scan",
"skippedByToggle": "Skipped (toggle off)",
"skippedByFlag": "Skipped (feature flag)",
"missingOnDisk": "Missing on disk",
"drift": "Unconfigured on disk (drift)"
},
"paths": {
"heading": "Configured paths",
"path": "Path",
"coverage": "Coverage",
"featureFlag": "Feature flag",
"description": "Description"
},
"coverage": {
"willScan": "Will scan",
"skippedByToggle": "Off",
"skippedByFlag": "Gated off",
"missingOnDisk": "Missing on disk"
},
"drift": {
"heading": "Drift detected: subdirectories not covered by any backup_paths row",
"caption": "These directories exist on disk but the walker will skip them. Either add a backup_paths row, move the files into a covered location, or — if they are runtime caches — confirm they are safe to exclude.",
"none": "No drift detected — every top-level subdirectory under STORAGE_PATH is either in backup_paths or in the expected non-backup allow-list."
}
},
"status": { "status": {
"inProgress": "Backup in progress...", "inProgress": "Backup in progress...",
"lastBackup": "Last backup", "lastBackup": "Last backup",
@@ -11,6 +11,7 @@ import {
Loader2, Loader2,
Shield, Shield,
ShieldCheck, ShieldCheck,
FolderTree,
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
@@ -23,9 +24,10 @@ import { BackupConfiguration } from '../../components/admin/BackupConfiguration'
import { BackupHistory } from '../../components/admin/BackupHistory'; import { BackupHistory } from '../../components/admin/BackupHistory';
import { RestoreWizard } from '../../components/admin/RestoreWizard'; import { RestoreWizard } from '../../components/admin/RestoreWizard';
import { BackupIntegrityCard } from '../../components/admin/BackupIntegrityCard'; import { BackupIntegrityCard } from '../../components/admin/BackupIntegrityCard';
import { BackupCoverageCard } from '../../components/admin/BackupCoverageCard';
import { api } from '../../config/api'; import { api } from '../../config/api';
type TabId = 'dashboard' | 'configuration' | 'history' | 'restore' | 'integrity'; type TabId = 'dashboard' | 'configuration' | 'history' | 'restore' | 'integrity' | 'coverage';
export const BackupManagement: React.FC = () => { export const BackupManagement: React.FC = () => {
const [activeTab, setActiveTab] = useState<TabId>('dashboard'); const [activeTab, setActiveTab] = useState<TabId>('dashboard');
@@ -38,6 +40,7 @@ export const BackupManagement: React.FC = () => {
{ id: 'history' as const, label: t('backup.tabs.history'), icon: History }, { id: 'history' as const, label: t('backup.tabs.history'), icon: History },
{ id: 'restore' as const, label: t('backup.tabs.restore'), icon: RefreshCw }, { id: 'restore' as const, label: t('backup.tabs.restore'), icon: RefreshCw },
{ id: 'integrity' as const, label: t('backup.tabs.integrity', 'Integrity'), icon: ShieldCheck }, { id: 'integrity' as const, label: t('backup.tabs.integrity', 'Integrity'), icon: ShieldCheck },
{ id: 'coverage' as const, label: t('backup.tabs.coverage', 'Coverage'), icon: FolderTree },
]; ];
const { data: backupStatus, isLoading: statusLoading } = useQuery({ const { data: backupStatus, isLoading: statusLoading } = useQuery({
@@ -227,6 +230,10 @@ export const BackupManagement: React.FC = () => {
{activeTab === 'integrity' && ( {activeTab === 'integrity' && (
<BackupIntegrityCard /> <BackupIntegrityCard />
)} )}
{activeTab === 'coverage' && (
<BackupCoverageCard />
)}
</div> </div>
</div> </div>
); );
+62
View File
@@ -245,6 +245,58 @@ export interface BackupIntegrityReport {
existsButNoHash: BackupIntegrityExistsButNoHashRow[]; existsButNoHash: BackupIntegrityExistsButNoHashRow[];
} }
// ---- Backup-coverage (Stage C of backup-hardening plan) -----------------
export type BackupPathCoverage =
| 'will-scan'
| 'skipped-by-toggle'
| 'skipped-by-feature-flag'
| 'missing-on-disk';
export interface BackupCoveragePath {
path: string;
includeInDefault: boolean;
featureFlag: string | null;
featureFlagValue: boolean | null;
displayOrder: number;
description: string | null;
existsOnDisk: boolean;
coverage: BackupPathCoverage;
}
export interface BackupCoverageDatabase {
mode: 'inline' | 'scheduled-only';
inlineDumpExplicitlyDisabled: boolean;
lastDumpAt: string | null;
lastDumpType: string | null;
lastDumpSizeBytes: number;
lastDumpFilePath: string | null;
lastDumpAgeMs: number | null;
lastDumpStale: boolean | null;
ok: boolean;
}
export interface BackupCoverageReport {
generatedAt: string;
database: BackupCoverageDatabase;
paths: BackupCoveragePath[];
drift: {
unconfiguredOnDisk: string[];
expectedNonBackupDirs: string[];
};
summary: {
configuredCount: number;
willScanCount: number;
skippedByToggleCount: number;
skippedByFeatureFlagCount: number;
missingOnDiskCount: number;
driftCount: number;
tableMissingFallbackInUse: boolean;
databaseOk: boolean;
overallOk: boolean;
};
}
export interface AdminProfile { export interface AdminProfile {
id: number; id: number;
username: string; username: string;
@@ -319,6 +371,16 @@ export const adminService = {
return response.data.report; return response.data.report;
}, },
// Backup-coverage diagnostic (Stage C). Answers "what will the
// next backup actually include / skip / silently miss?" — read-only,
// no parameters. See backupCoverageService.js for the full report shape.
async getBackupCoverage(): Promise<BackupCoverageReport> {
const response = await api.get<{ report: BackupCoverageReport }>(
'/admin/system-health/backup-coverage',
);
return response.data.report;
},
// Format activity message // Format activity message
formatActivityMessage(activity: Activity): string { formatActivityMessage(activity: Activity): string {
// Feature-flag toggles carry a `changed` diff in metadata. Render // Feature-flag toggles carry a `changed` diff in metadata. Render