feat(backup): UI for backup-integrity verifier — tab + post-restore CTA
Frontend half of the diagnostic shipped in 4812fcd. Adds:
- BackupIntegrityCard component — runs the check on demand, surfaces
the five summary counters (total / verifiedOk / existsButNoHash /
missing / hashMismatches), and expands collapsible result tables
for missing files + hash mismatches. existsButNoHash is exposed as
a separate amber-toned bucket so admins can distinguish hash-
verified evidence from existence-only at a glance — the latter is
explicitly weaker in a legal dispute and the UI says so.
- "Integrity" tab on BackupManagement, alongside the existing
Dashboard / Configuration / History / Restore tabs. Card is
portable — when the System Health page (backlog item) lands it
can lift the component without changes.
- Post-restore CTA on the RestoreWizard success card (D2 follow-
through): "Verify document integrity now" button that switches
the parent tab to Integrity. The audit trail captured at sign /
issue time is worth nothing if the documents it refers to are
missing from the restored copy — verifier surfaces that drift
in one click before the admin trusts the restored state.
i18n strings added in EN + DE (per user_languages — only those two
are native; other locales fall back to the English defaults and
should be flagged for native-speaker review per
feedback_translation_flagging if anyone picks them up).
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ShieldCheck,
|
||||
ShieldAlert,
|
||||
FileX,
|
||||
Hash,
|
||||
HelpCircle,
|
||||
Play,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
import { Card, Button } from '../common';
|
||||
import { adminService, BackupIntegrityReport } from '../../services/admin.service';
|
||||
|
||||
/**
|
||||
* BackupIntegrityCard — on-demand verifier for CRM document artefacts.
|
||||
*
|
||||
* Walks every `*_path` column on quotes / contracts / invoices and
|
||||
* confirms (a) the referenced file exists on disk, (b) where a SHA-256
|
||||
* is stored, the file's bytes hash to the expected value. Surfaces
|
||||
* three failure buckets:
|
||||
*
|
||||
* - missing — `*_path` set, file not on disk (broken FK)
|
||||
* - hashMismatches — file exists but bytes don't match the stored hash
|
||||
* - existsButNoHash — verified by existence only; weaker evidence
|
||||
*
|
||||
* Designed to be portable. Currently embedded as a tab on
|
||||
* `BackupManagement.tsx`; when the System Health page (backlog item)
|
||||
* lands, this same component can be lifted there without changes.
|
||||
*/
|
||||
export const BackupIntegrityCard: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [report, setReport] = useState<BackupIntegrityReport | null>(null);
|
||||
const [expanded, setExpanded] = useState<'missing' | 'hashMismatches' | null>(null);
|
||||
|
||||
const runCheck = useMutation({
|
||||
mutationFn: () => adminService.getBackupIntegrity(),
|
||||
onSuccess: (data) => {
|
||||
setReport(data);
|
||||
// Auto-expand whichever failure bucket has entries, prioritising
|
||||
// the more severe one (missing > hashMismatches).
|
||||
if (data.summary.missingFiles > 0) setExpanded('missing');
|
||||
else if (data.summary.hashMismatches > 0) setExpanded('hashMismatches');
|
||||
else setExpanded(null);
|
||||
},
|
||||
});
|
||||
|
||||
const summary = report?.summary;
|
||||
const isHealthy = report
|
||||
&& summary
|
||||
&& summary.missingFiles === 0
|
||||
&& summary.hashMismatches === 0;
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{isHealthy ? (
|
||||
<ShieldCheck className="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||
) : report ? (
|
||||
<ShieldAlert className="w-5 h-5 text-red-600 dark:text-red-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.integrity.title', 'Document integrity')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 max-w-2xl">
|
||||
{t(
|
||||
'backup.integrity.description',
|
||||
'Verifies every CRM document (quote / contract / invoice / signature) referenced from the database actually exists on disk and — where a hash is stored — its bytes still match. Read-only, on-demand.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => runCheck.mutate()}
|
||||
disabled={runCheck.isPending}
|
||||
leftIcon={
|
||||
runCheck.isPending
|
||||
? <Loader2 className="w-4 h-4 animate-spin" />
|
||||
: <Play className="w-4 h-4" />
|
||||
}
|
||||
>
|
||||
{runCheck.isPending
|
||||
? t('backup.integrity.running', 'Checking…')
|
||||
: t('backup.integrity.runNow', 'Run check now')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{runCheck.isError && (
|
||||
<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.integrity.error', 'Check failed: {{message}}', {
|
||||
message: (runCheck.error as Error)?.message ?? 'unknown error',
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{report && summary && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 mb-4">
|
||||
<Counter
|
||||
label={t('backup.integrity.summary.total', 'Total')}
|
||||
value={summary.totalRows}
|
||||
tone="neutral"
|
||||
/>
|
||||
<Counter
|
||||
label={t('backup.integrity.summary.verifiedOk', 'Hash-verified')}
|
||||
value={summary.verifiedOk}
|
||||
tone="green"
|
||||
icon={<Hash className="w-4 h-4" />}
|
||||
/>
|
||||
<Counter
|
||||
label={t('backup.integrity.summary.existsButNoHash', 'Exists only')}
|
||||
value={summary.existsButNoHash}
|
||||
tone="amber"
|
||||
icon={<HelpCircle className="w-4 h-4" />}
|
||||
tooltip={t(
|
||||
'backup.integrity.summary.existsButNoHashHint',
|
||||
'File found, but no SHA-256 is stored for it (quote/invoice PDFs, signature drawings). Existence-only is weaker evidence in a dispute.',
|
||||
)}
|
||||
/>
|
||||
<Counter
|
||||
label={t('backup.integrity.summary.missingFiles', 'Missing')}
|
||||
value={summary.missingFiles}
|
||||
tone={summary.missingFiles > 0 ? 'red' : 'neutral'}
|
||||
icon={<FileX className="w-4 h-4" />}
|
||||
onClick={summary.missingFiles > 0
|
||||
? () => setExpanded(expanded === 'missing' ? null : 'missing')
|
||||
: undefined}
|
||||
/>
|
||||
<Counter
|
||||
label={t('backup.integrity.summary.hashMismatches', 'Hash mismatches')}
|
||||
value={summary.hashMismatches}
|
||||
tone={summary.hashMismatches > 0 ? 'red' : 'neutral'}
|
||||
icon={<ShieldAlert className="w-4 h-4" />}
|
||||
onClick={summary.hashMismatches > 0
|
||||
? () => setExpanded(expanded === 'hashMismatches' ? null : 'hashMismatches')
|
||||
: undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-3">
|
||||
{t('backup.integrity.scannedAt', 'Last checked: {{when}}', {
|
||||
when: format(new Date(report.scannedAt), 'yyyy-MM-dd HH:mm:ss'),
|
||||
})}
|
||||
</p>
|
||||
|
||||
{expanded === 'missing' && summary.missingFiles > 0 && (
|
||||
<ResultTable
|
||||
title={t('backup.integrity.missing.heading', 'Missing files')}
|
||||
caption={t(
|
||||
'backup.integrity.missing.caption',
|
||||
'These rows reference a path that does not exist on disk. After a restore, this means the artefact was lost from the backup chain; for fresh installs, it usually means the file was deleted manually.',
|
||||
)}
|
||||
rows={report.missing.map((m) => ({
|
||||
table: m.table,
|
||||
rowId: m.rowId,
|
||||
column: m.column,
|
||||
detail: m.expectedPath,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{expanded === 'hashMismatches' && summary.hashMismatches > 0 && (
|
||||
<ResultTable
|
||||
title={t('backup.integrity.hashMismatches.heading', 'Hash mismatches')}
|
||||
caption={t(
|
||||
'backup.integrity.hashMismatches.caption',
|
||||
'The file exists but its current bytes do not match the SHA-256 captured at issue / sign time. Indicates tampering, bit-rot, or a restore that pulled in a different copy than the original.',
|
||||
)}
|
||||
rows={report.hashMismatches.map((m) => ({
|
||||
table: m.table,
|
||||
rowId: m.rowId,
|
||||
column: m.column,
|
||||
detail: `${m.expectedPath} (expected ${m.expectedSha.slice(0, 12)}…, got ${m.actualSha.slice(0, 12)}…)`,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!report && !runCheck.isPending && (
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 italic">
|
||||
{t(
|
||||
'backup.integrity.emptyState',
|
||||
'No check has been run yet in this session. Click "Run check now" to scan the document estate.',
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
type Tone = 'neutral' | 'green' | 'amber' | 'red';
|
||||
|
||||
const TONE_CLASSES: 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',
|
||||
};
|
||||
|
||||
const Counter: React.FC<{
|
||||
label: string;
|
||||
value: number;
|
||||
tone: Tone;
|
||||
icon?: React.ReactNode;
|
||||
tooltip?: string;
|
||||
onClick?: () => void;
|
||||
}> = ({ label, value, tone, icon, tooltip, onClick }) => {
|
||||
const interactive = Boolean(onClick);
|
||||
const classes = `rounded-lg p-3 ${TONE_CLASSES[tone]} ${
|
||||
interactive ? 'cursor-pointer hover:ring-2 hover:ring-offset-1 hover:ring-current/30 transition' : ''
|
||||
}`;
|
||||
return (
|
||||
<div
|
||||
className={classes}
|
||||
onClick={onClick}
|
||||
title={tooltip}
|
||||
role={interactive ? 'button' : undefined}
|
||||
tabIndex={interactive ? 0 : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide opacity-80">
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<div className="text-2xl font-semibold mt-1 tabular-nums">{value}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ResultTable: React.FC<{
|
||||
title: string;
|
||||
caption: string;
|
||||
rows: Array<{ table: string; rowId: number; column: string; detail: string }>;
|
||||
}> = ({ title, caption, rows }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="mt-4 border border-neutral-200 dark:border-neutral-700 rounded-lg overflow-hidden">
|
||||
<div className="p-3 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">{title}</h4>
|
||||
<p className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">{caption}</p>
|
||||
</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.integrity.results.table', 'Table')}</th>
|
||||
<th className="px-3 py-2">{t('backup.integrity.results.rowId', 'Row id')}</th>
|
||||
<th className="px-3 py-2">{t('backup.integrity.results.column', 'Column')}</th>
|
||||
<th className="px-3 py-2">{t('backup.integrity.results.detail', 'Detail')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => (
|
||||
<tr
|
||||
key={`${r.table}-${r.rowId}-${r.column}-${i}`}
|
||||
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">
|
||||
{r.table}
|
||||
</td>
|
||||
<td className="px-3 py-2 tabular-nums text-neutral-700 dark:text-neutral-300">
|
||||
{r.rowId}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-neutral-700 dark:text-neutral-300">
|
||||
{r.column}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-neutral-700 dark:text-neutral-300 break-all">
|
||||
{r.detail}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -21,7 +21,8 @@ import {
|
||||
Eye,
|
||||
Calendar,
|
||||
Clock,
|
||||
AlertCircle
|
||||
AlertCircle,
|
||||
ShieldCheck
|
||||
} from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { toast } from 'react-toastify';
|
||||
@@ -29,7 +30,7 @@ import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { Button, Card, Input, Loading } from '../common';
|
||||
import { api } from '../../config/api';
|
||||
|
||||
export const RestoreWizard = () => {
|
||||
export const RestoreWizard = ({ onVerifyIntegrity } = {}) => {
|
||||
const { t } = useTranslation();
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
|
||||
@@ -650,13 +651,33 @@ export const RestoreWizard = () => {
|
||||
<div className="bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg p-4">
|
||||
<div className="flex">
|
||||
<CheckCircle className="h-5 w-5 text-green-400 mt-0.5" />
|
||||
<div className="ml-3">
|
||||
<div className="ml-3 flex-1">
|
||||
<h3 className="text-sm font-medium text-green-800 dark:text-green-200">
|
||||
{t('backup.restore.progress.success.title')}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-green-700 dark:text-green-300">
|
||||
{t('backup.restore.progress.success.message')}
|
||||
</p>
|
||||
{/* Post-restore CTA: jump to the integrity check (D2). The
|
||||
audit trail captured at sign / issue time is worth
|
||||
nothing if the documents it refers to are missing
|
||||
from the restored copy — verifier surfaces that
|
||||
drift in one click before the admin trusts the
|
||||
restored state. */}
|
||||
{onVerifyIntegrity && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
onClick={onVerifyIntegrity}
|
||||
leftIcon={<ShieldCheck className="w-4 h-4" />}
|
||||
>
|
||||
{t(
|
||||
'backup.restore.progress.success.verifyIntegrity',
|
||||
'Verify document integrity now',
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -230,7 +230,39 @@
|
||||
"dashboard": "Dashboard",
|
||||
"configuration": "Konfiguration",
|
||||
"history": "Backup-Verlauf",
|
||||
"restore": "Wiederherstellung"
|
||||
"restore": "Wiederherstellung",
|
||||
"integrity": "Integrität"
|
||||
},
|
||||
"integrity": {
|
||||
"title": "Dokumentintegrität",
|
||||
"description": "Prüft, ob jedes in der Datenbank referenzierte CRM-Dokument (Angebot / Vertrag / Rechnung / Unterschrift) tatsächlich auf der Festplatte existiert und — sofern ein Hash gespeichert ist — die Datei-Bytes weiterhin übereinstimmen. Nur lesend, auf Abruf.",
|
||||
"runNow": "Prüfung starten",
|
||||
"running": "Prüfe…",
|
||||
"error": "Prüfung fehlgeschlagen: {{message}}",
|
||||
"scannedAt": "Zuletzt geprüft: {{when}}",
|
||||
"emptyState": "In dieser Sitzung wurde noch keine Prüfung ausgeführt. Klicke auf \"Prüfung starten\", um den Dokumentbestand zu durchsuchen.",
|
||||
"summary": {
|
||||
"total": "Gesamt",
|
||||
"verifiedOk": "Hash-verifiziert",
|
||||
"existsButNoHash": "Nur Existenz",
|
||||
"existsButNoHashHint": "Datei gefunden, aber kein SHA-256 gespeichert (Angebots-/Rechnungs-PDFs, Unterschrifts-Zeichnungen). Nur-Existenz ist im Streitfall schwächeres Beweismaterial.",
|
||||
"missingFiles": "Fehlend",
|
||||
"hashMismatches": "Hash-Abweichungen"
|
||||
},
|
||||
"missing": {
|
||||
"heading": "Fehlende Dateien",
|
||||
"caption": "Diese Zeilen verweisen auf einen Pfad, der nicht auf der Festplatte existiert. Nach einer Wiederherstellung bedeutet das, dass das Artefakt aus der Sicherungskette verloren ging; bei frischen Installationen wurde die Datei meist manuell gelöscht."
|
||||
},
|
||||
"hashMismatches": {
|
||||
"heading": "Hash-Abweichungen",
|
||||
"caption": "Die Datei existiert, aber ihre aktuellen Bytes stimmen nicht mit dem zum Ausstellungs-/Signierzeitpunkt erfassten SHA-256 überein. Deutet auf Manipulation, Bit-Rot oder eine Wiederherstellung mit einer abweichenden Kopie hin."
|
||||
},
|
||||
"results": {
|
||||
"table": "Tabelle",
|
||||
"rowId": "Zeilen-ID",
|
||||
"column": "Spalte",
|
||||
"detail": "Detail"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"inProgress": "Backup läuft...",
|
||||
@@ -517,7 +549,8 @@
|
||||
"restoreLogs": "Wiederherstellungsprotokolle",
|
||||
"success": {
|
||||
"title": "Wiederherstellung erfolgreich abgeschlossen",
|
||||
"message": "Ihre Daten wurden wiederhergestellt. Bitte überprüfen Sie, ob alles korrekt funktioniert."
|
||||
"message": "Ihre Daten wurden wiederhergestellt. Bitte überprüfen Sie, ob alles korrekt funktioniert.",
|
||||
"verifyIntegrity": "Dokumentintegrität jetzt prüfen"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
|
||||
@@ -2121,7 +2121,39 @@
|
||||
"dashboard": "Dashboard",
|
||||
"configuration": "Configuration",
|
||||
"history": "Backup History",
|
||||
"restore": "Restore"
|
||||
"restore": "Restore",
|
||||
"integrity": "Integrity"
|
||||
},
|
||||
"integrity": {
|
||||
"title": "Document integrity",
|
||||
"description": "Verifies every CRM document (quote / contract / invoice / signature) referenced from the database actually exists on disk and — where a hash is stored — its bytes still match. Read-only, on-demand.",
|
||||
"runNow": "Run check now",
|
||||
"running": "Checking…",
|
||||
"error": "Check failed: {{message}}",
|
||||
"scannedAt": "Last checked: {{when}}",
|
||||
"emptyState": "No check has been run yet in this session. Click \"Run check now\" to scan the document estate.",
|
||||
"summary": {
|
||||
"total": "Total",
|
||||
"verifiedOk": "Hash-verified",
|
||||
"existsButNoHash": "Exists only",
|
||||
"existsButNoHashHint": "File found, but no SHA-256 is stored for it (quote/invoice PDFs, signature drawings). Existence-only is weaker evidence in a dispute.",
|
||||
"missingFiles": "Missing",
|
||||
"hashMismatches": "Hash mismatches"
|
||||
},
|
||||
"missing": {
|
||||
"heading": "Missing files",
|
||||
"caption": "These rows reference a path that does not exist on disk. After a restore, this means the artefact was lost from the backup chain; for fresh installs, it usually means the file was deleted manually."
|
||||
},
|
||||
"hashMismatches": {
|
||||
"heading": "Hash mismatches",
|
||||
"caption": "The file exists but its current bytes do not match the SHA-256 captured at issue / sign time. Indicates tampering, bit-rot, or a restore that pulled in a different copy than the original."
|
||||
},
|
||||
"results": {
|
||||
"table": "Table",
|
||||
"rowId": "Row id",
|
||||
"column": "Column",
|
||||
"detail": "Detail"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"inProgress": "Backup in progress...",
|
||||
@@ -2408,7 +2440,8 @@
|
||||
"restoreLogs": "Restore Logs",
|
||||
"success": {
|
||||
"title": "Restore Completed Successfully",
|
||||
"message": "Your data has been restored. Please verify everything is working correctly."
|
||||
"message": "Your data has been restored. Please verify everything is working correctly.",
|
||||
"verifyIntegrity": "Verify document integrity now"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Clock,
|
||||
Loader2,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
@@ -21,9 +22,10 @@ import { BackupDashboard } from '../../components/admin/BackupDashboard';
|
||||
import { BackupConfiguration } from '../../components/admin/BackupConfiguration';
|
||||
import { BackupHistory } from '../../components/admin/BackupHistory';
|
||||
import { RestoreWizard } from '../../components/admin/RestoreWizard';
|
||||
import { BackupIntegrityCard } from '../../components/admin/BackupIntegrityCard';
|
||||
import { api } from '../../config/api';
|
||||
|
||||
type TabId = 'dashboard' | 'configuration' | 'history' | 'restore';
|
||||
type TabId = 'dashboard' | 'configuration' | 'history' | 'restore' | 'integrity';
|
||||
|
||||
export const BackupManagement: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('dashboard');
|
||||
@@ -35,6 +37,7 @@ export const BackupManagement: React.FC = () => {
|
||||
{ id: 'configuration' as const, label: t('backup.tabs.configuration'), icon: Settings },
|
||||
{ id: 'history' as const, label: t('backup.tabs.history'), icon: History },
|
||||
{ id: 'restore' as const, label: t('backup.tabs.restore'), icon: RefreshCw },
|
||||
{ id: 'integrity' as const, label: t('backup.tabs.integrity', 'Integrity'), icon: ShieldCheck },
|
||||
];
|
||||
|
||||
const { data: backupStatus, isLoading: statusLoading } = useQuery({
|
||||
@@ -218,7 +221,11 @@ export const BackupManagement: React.FC = () => {
|
||||
)}
|
||||
|
||||
{activeTab === 'restore' && (
|
||||
<RestoreWizard />
|
||||
<RestoreWizard onVerifyIntegrity={() => setActiveTab('integrity')} />
|
||||
)}
|
||||
|
||||
{activeTab === 'integrity' && (
|
||||
<BackupIntegrityCard />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -201,6 +201,50 @@ export interface Activity {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// Backup-integrity verifier — diagnostic endpoint that walks every
|
||||
// CRM document-artefact path column and confirms files exist on disk
|
||||
// (plus SHA-256 match where the schema stores one). Mirrors the
|
||||
// shape returned by backupIntegrityService.verifyDocumentArtefacts.
|
||||
export type BackupIntegrityScope =
|
||||
| 'quote'
|
||||
| 'contract'
|
||||
| 'contract-signature'
|
||||
| 'invoice';
|
||||
|
||||
export interface BackupIntegrityMissingRow {
|
||||
table: string;
|
||||
rowId: number;
|
||||
column: string;
|
||||
expectedPath: string;
|
||||
}
|
||||
|
||||
export interface BackupIntegrityHashMismatchRow extends BackupIntegrityMissingRow {
|
||||
expectedSha: string;
|
||||
actualSha: string;
|
||||
}
|
||||
|
||||
export interface BackupIntegrityExistsButNoHashRow {
|
||||
table: string;
|
||||
rowId: number;
|
||||
column: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface BackupIntegrityReport {
|
||||
scannedAt: string;
|
||||
scopes: BackupIntegrityScope[];
|
||||
summary: {
|
||||
totalRows: number;
|
||||
verifiedOk: number;
|
||||
missingFiles: number;
|
||||
hashMismatches: number;
|
||||
existsButNoHash: number;
|
||||
};
|
||||
missing: BackupIntegrityMissingRow[];
|
||||
hashMismatches: BackupIntegrityHashMismatchRow[];
|
||||
existsButNoHash: BackupIntegrityExistsButNoHashRow[];
|
||||
}
|
||||
|
||||
export interface AdminProfile {
|
||||
id: number;
|
||||
username: string;
|
||||
@@ -262,6 +306,19 @@ export const adminService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Backup-integrity verifier (read-only diagnostic). `scope` filters
|
||||
// which document classes to walk; omit for a full scan.
|
||||
async getBackupIntegrity(
|
||||
scope?: BackupIntegrityScope[],
|
||||
): Promise<BackupIntegrityReport> {
|
||||
const params = scope && scope.length > 0 ? { scope: scope.join(',') } : undefined;
|
||||
const response = await api.get<{ report: BackupIntegrityReport }>(
|
||||
'/admin/system-health/backup-integrity',
|
||||
{ params },
|
||||
);
|
||||
return response.data.report;
|
||||
},
|
||||
|
||||
// Format activity message
|
||||
formatActivityMessage(activity: Activity): string {
|
||||
// Feature-flag toggles carry a `changed` diff in metadata. Render
|
||||
|
||||
Reference in New Issue
Block a user