Merge branch 'beta' of https://github.com/the-luap/picpeak into feat/crm-improvements

This commit is contained in:
Luca
2026-06-02 14:17:46 +02:00
45 changed files with 6096 additions and 310 deletions
@@ -0,0 +1,433 @@
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';
// Locale-aware formatters per [[feedback_respect_general_format_settings]].
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
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 { formatDateTime } = useLocalizedDate();
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: formatDateTime(new Date(data.generatedAt)),
})}
</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 { formatDateTime } = useLocalizedDate();
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={`${formatDateTime(new Date(database.lastDumpAt))}${
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`;
}
@@ -16,7 +16,10 @@ import {
AlertTriangle,
Info
} from 'lucide-react';
import { format, formatDistanceToNow } from 'date-fns';
// Per [[feedback_respect_general_format_settings]] — route every
// displayed date/time through useLocalizedDate so general_date_format
// and general_time_format settings apply uniformly.
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Card, Button } from '../common';
export type HealthStatus = 'excellent' | 'good' | 'warning' | 'critical';
@@ -38,11 +41,15 @@ interface BackupRecord {
backup_type: string;
created_at: string;
duration_seconds: number;
started_at?: string;
error_message?: string;
statistics?: BackupStatistics;
}
interface BackupStatus {
lastBackup?: BackupRecord;
lastBackup?: BackupRecord; // most recent attempt, any status
lastSuccessfulBackup?: BackupRecord; // most recent completed run
zombieRuns?: BackupRecord[]; // running > 30min, likely crashed
totalBackups?: number;
recentBackups?: BackupRecord[];
}
@@ -105,18 +112,41 @@ const healthColors: Record<HealthStatus, string> = {
export const BackupDashboard: React.FC<BackupDashboardProps> = ({ status, config, onRunBackup, isBackupRunning }) => {
const { t } = useTranslation();
const lastBackup = status?.lastBackup;
const statistics = lastBackup?.statistics ?? {};
const { format, formatTime, formatDateTime, formatDistanceToNow } = useLocalizedDate();
const lastBackup = status?.lastBackup; // any status
const lastSuccessfulBackup = status?.lastSuccessfulBackup; // status='completed' only
const zombieRuns = status?.zombieRuns ?? [];
const statistics = lastSuccessfulBackup?.statistics ?? lastBackup?.statistics ?? {};
const isConfigured = config && config.backup_destination_type;
const isEnabled = config?.backup_enabled;
// Use the most recent SUCCESSFUL backup as the "age" reference for
// health, so a transient failure doesn't immediately drop the score
// — but call out failed/running/zombie attempts explicitly so the
// admin sees them at a glance.
const getHealthScore = (): { score: number; status: HealthStatus; message: string } => {
if (!lastBackup) return { score: 0, status: 'critical', message: t('backup.dashboard.healthMessages.noBackups') };
if (!lastSuccessfulBackup) {
// No successful backup ever recorded.
if (lastBackup?.status === 'failed') {
return { score: 0, status: 'critical', message: t('backup.dashboard.healthMessages.lastBackupFailed') };
}
return { score: 0, status: 'critical', message: t('backup.dashboard.healthMessages.noBackups') };
}
const hoursSinceBackup = (Date.now() - new Date(lastBackup.created_at).getTime()) / (1000 * 60 * 60);
const hoursSinceBackup = (Date.now() - new Date(lastSuccessfulBackup.created_at).getTime()) / (1000 * 60 * 60);
if (lastBackup.status === 'failed') {
return { score: 0, status: 'critical', message: t('backup.dashboard.healthMessages.lastBackupFailed') };
// A successful backup exists. Bias the score on its age, but if
// the MOST RECENT attempt failed, downgrade the message so the
// admin sees the regression even though older backups are fine.
const latestAttemptFailed = lastBackup && lastBackup.id !== lastSuccessfulBackup.id
&& lastBackup.status === 'failed';
if (latestAttemptFailed) {
return {
score: 50,
status: 'warning',
message: t('backup.dashboard.healthMessages.lastBackupFailed'),
};
}
if (hoursSinceBackup < 24) {
@@ -190,9 +220,43 @@ export const BackupDashboard: React.FC<BackupDashboardProps> = ({ status, config
<div className="flex-1">
<p className="text-neutral-700 dark:text-neutral-300 font-medium">{health.message}</p>
{lastBackup && (
{/* Show the last successful backup explicitly — previously
this read `lastBackup.created_at` which silently rendered
a failed/running row as if it were the last success. */}
{lastSuccessfulBackup && (
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-1">
Last successful backup: {formatDistanceToNow(new Date(lastBackup.created_at), { addSuffix: true })}
{t('backup.dashboard.lastSuccessful', 'Last successful backup')}: {formatDistanceToNow(new Date(lastSuccessfulBackup.created_at), { addSuffix: true })}
</p>
)}
{/* If the most recent attempt is NOT the last successful
run, surface it separately so the admin sees the
divergence (latest attempt failed or running). */}
{lastBackup && lastBackup.id !== lastSuccessfulBackup?.id && (
<p className={`text-sm mt-1 ${
lastBackup.status === 'failed'
? 'text-red-600 dark:text-red-400 font-medium'
: lastBackup.status === 'running'
? 'text-blue-600 dark:text-blue-400'
: 'text-neutral-500 dark:text-neutral-400'
}`}>
{t('backup.dashboard.lastAttempt', 'Last attempt')}: {formatDistanceToNow(new Date(lastBackup.created_at), { addSuffix: true })}
{' · '}
{t(`backup.dashboard.status.${lastBackup.status}`, lastBackup.status)}
{lastBackup.status === 'failed' && lastBackup.error_message && (
<span className="block text-xs text-red-600 dark:text-red-400 mt-0.5">
{lastBackup.error_message.split('\n')[0].slice(0, 200)}
</span>
)}
</p>
)}
{/* Zombie warning — running >30min, almost certainly crashed.
Admin needs to know they may be looking at a hung row
that won't ever flip to completed. */}
{zombieRuns.length > 0 && (
<p className="text-sm mt-1 text-amber-700 dark:text-amber-300 font-medium">
{t('backup.dashboard.zombieRuns',
'{{count}} backup(s) running >30min — may have crashed without completing',
{ count: zombieRuns.length })}
</p>
)}
@@ -225,7 +289,7 @@ export const BackupDashboard: React.FC<BackupDashboardProps> = ({ status, config
label={t('backup.dashboard.stats.totalBackups')}
value={status?.totalBackups || 0}
color="blue"
subtext={lastBackup ? `${t('backup.dashboard.stats.last')}: ${format(new Date(lastBackup.created_at), 'PP')}` : t('backup.dashboard.stats.noBackupsYet')}
subtext={lastBackup ? `${t('backup.dashboard.stats.last')}: ${format(new Date(lastBackup.created_at))}` : t('backup.dashboard.stats.noBackupsYet')}
/>
<StatCard
@@ -241,7 +305,7 @@ export const BackupDashboard: React.FC<BackupDashboardProps> = ({ status, config
label={t('backup.dashboard.stats.lastDuration')}
value={lastBackup ? `${Math.round(lastBackup.duration_seconds / 60)}m` : 'N/A'}
color="purple"
subtext={lastBackup ? format(new Date(lastBackup.created_at), 'p') : ''}
subtext={lastBackup ? formatTime(new Date(lastBackup.created_at)) : ''}
/>
<StatCard
@@ -273,7 +337,7 @@ export const BackupDashboard: React.FC<BackupDashboardProps> = ({ status, config
{t('backup.dashboard.backupType', { type: backup.backup_type })}
</p>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{format(new Date(backup.created_at), 'PPp')}
{formatDateTime(new Date(backup.created_at))}
</p>
</div>
</div>
+101 -20
View File
@@ -20,11 +20,17 @@ import {
RefreshCw,
Loader2
} from 'lucide-react';
import { format, formatDistanceToNow } from 'date-fns';
import { toast } from 'react-toastify';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Button, Card, Input, Loading } from '../common';
import { api } from '../../config/api';
// Per [[feedback_respect_general_format_settings]]: route every displayed
// date/time through useLocalizedDate so the admin's general_date_format +
// general_time_format settings apply uniformly. Previously the backup
// History pane used raw date-fns format() with hard-coded 'p' (12-hour
// AM/PM) and 'PPP' (US-locale long date), which ignored the settings —
// Ralf 2026-05-31 flagged "11:25 PM" on a 24h-configured install.
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
const statusIcons = {
completed: { icon: CheckCircle, color: 'text-green-500' },
@@ -48,6 +54,12 @@ export const BackupHistory = () => {
const [filterStatus, setFilterStatus] = useState('all');
const [currentPage, setCurrentPage] = useState(1);
const queryClient = useQueryClient();
// Locale-aware formatters that respect admin's general_date_format +
// general_time_format settings. See useLocalizedDate.ts for the full
// contract; formatTime gives "HH:mm" (24h) or "h:mm a" (12h) based on
// the setting, format(date) honors general_date_format, and
// formatDistanceToNow returns "2 minutes ago" in the admin's i18n locale.
const { format, formatTime, formatDistanceToNow } = useLocalizedDate();
// Fetch backup history
const { data, isLoading, refetch } = useQuery({
@@ -90,7 +102,7 @@ export const BackupHistory = () => {
};
const handleDelete = (backup) => {
if (window.confirm(`Are you sure you want to delete this backup from ${format(new Date(backup.created_at), 'PPP')}?`)) {
if (window.confirm(`Are you sure you want to delete this backup from ${format(new Date(backup.created_at))}?`)) {
deleteMutation.mutate(backup.id);
}
};
@@ -200,10 +212,10 @@ export const BackupHistory = () => {
<td className="px-6 py-4 whitespace-nowrap">
<div>
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{format(new Date(backup.created_at), 'PPP')}
{format(new Date(backup.created_at))}
</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{format(new Date(backup.created_at), 'p')} {formatDistanceToNow(new Date(backup.created_at), { addSuffix: true })}
{formatTime(new Date(backup.created_at))} {formatDistanceToNow(new Date(backup.created_at), { addSuffix: true })}
</p>
</div>
</td>
@@ -236,7 +248,7 @@ export const BackupHistory = () => {
</button>
{backup.manifest_path && (
<button
onClick={() => window.open(`/admin/backup/download/${backup.id}`, '_blank')}
onClick={() => window.open(`/api/admin/backup/download/${backup.id}`, '_blank')}
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
title={t('backup.actions.download')}
>
@@ -270,18 +282,27 @@ export const BackupHistory = () => {
</div>
<div className="flex justify-between">
<span className="text-neutral-500 dark:text-neutral-400">{t('backup.history.details.started')}:</span>
<span className="text-neutral-900 dark:text-neutral-100">{format(new Date(backup.created_at), 'p')}</span>
<span className="text-neutral-900 dark:text-neutral-100">{formatTime(new Date(backup.created_at))}</span>
</div>
{backup.completed_at && (
<div className="flex justify-between">
<span className="text-neutral-500 dark:text-neutral-400">{t('backup.history.details.completed')}:</span>
<span className="text-neutral-900 dark:text-neutral-100">{format(new Date(backup.completed_at), 'p')}</span>
<span className="text-neutral-900 dark:text-neutral-100">{formatTime(new Date(backup.completed_at))}</span>
</div>
)}
</div>
</div>
{/* Content Backed Up */}
{/* Content Backed Up
Two render paths depending on what the backend
provided:
- NEW: per_path map { "events/active": {count, size}, ... }
from Stage B's walker. One row per path,
ordered by display_order.
- LEGACY: fall back to Photos + Archives +
"Other" bucket so the arithmetic still adds
up when restoring a backup taken before this
change shipped. */}
<div className="space-y-2">
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{t('backup.history.details.contentBackedUp')}</h4>
<div className="space-y-2">
@@ -289,18 +310,78 @@ export const BackupHistory = () => {
<Database className={`h-4 w-4 ${stats.database_backed_up ? 'text-green-500' : 'text-neutral-300 dark:text-neutral-600'}`} />
<span className="text-sm text-neutral-700 dark:text-neutral-300">{t('backup.configuration.whatToBackup.database')}</span>
</div>
<div className="flex items-center space-x-2">
<Image className={`h-4 w-4 ${stats.photos_backed_up > 0 ? 'text-green-500' : 'text-neutral-300 dark:text-neutral-600'}`} />
<span className="text-sm text-neutral-700 dark:text-neutral-300">
Photos ({stats.photos_backed_up || 0} of {stats.total_photos || 0})
</span>
</div>
<div className="flex items-center space-x-2">
<FileArchive className={`h-4 w-4 ${stats.archives_backed_up > 0 ? 'text-green-500' : 'text-neutral-300 dark:text-neutral-600'}`} />
<span className="text-sm text-neutral-700 dark:text-neutral-300">
Archives ({stats.archives_backed_up || 0})
</span>
</div>
{(() => {
// Per-path breakdown when present
const perPath = stats.per_path || stats.perPath;
if (perPath && Object.keys(perPath).length > 0) {
const formatSize = (bytes) => {
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]}`;
};
// Sort by path string so the order is stable across renders;
// backend uses backup_paths.display_order to drive the walker
// but doesn't carry order into per_path map — alphabetic is
// fine for the display.
const entries = Object.entries(perPath).sort(([a], [b]) => a.localeCompare(b));
return (
<>
{entries.map(([pathKey, info]) => (
<div key={pathKey} className="flex items-center space-x-2">
<FileArchive className={`h-4 w-4 ${info.count > 0 ? 'text-green-500' : 'text-neutral-300 dark:text-neutral-600'}`} />
<span className="text-sm text-neutral-700 dark:text-neutral-300 font-mono">
{pathKey}
</span>
<span className="text-sm text-neutral-500 dark:text-neutral-400 ml-auto">
{info.count} {info.size ? `(${formatSize(info.size)})` : ''}
</span>
</div>
))}
<div className="flex items-center space-x-2 pt-1 border-t border-neutral-200 dark:border-neutral-700">
<span className="text-xs uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
{t('backup.history.details.totalFiles', 'Total files')}: {stats.files_processed || 0}
</span>
</div>
</>
);
}
// LEGACY rendering for backups taken before
// per_path was emitted.
const total = Number(stats.files_processed) || 0;
const accounted =
(Number(stats.photos_backed_up) || 0)
+ (Number(stats.archives_backed_up) || 0);
const other = Math.max(total - accounted, 0);
return (
<>
<div className="flex items-center space-x-2">
<Image className={`h-4 w-4 ${stats.photos_backed_up > 0 ? 'text-green-500' : 'text-neutral-300 dark:text-neutral-600'}`} />
<span className="text-sm text-neutral-700 dark:text-neutral-300">
Photos ({stats.photos_backed_up || 0} of {stats.total_photos || 0})
</span>
</div>
<div className="flex items-center space-x-2">
<FileArchive className={`h-4 w-4 ${stats.archives_backed_up > 0 ? 'text-green-500' : 'text-neutral-300 dark:text-neutral-600'}`} />
<span className="text-sm text-neutral-700 dark:text-neutral-300">
Archives ({stats.archives_backed_up || 0})
</span>
</div>
<div className="flex items-center space-x-2">
<FileArchive className={`h-4 w-4 ${other > 0 ? 'text-green-500' : 'text-neutral-300 dark:text-neutral-600'}`} />
<span className="text-sm text-neutral-700 dark:text-neutral-300">
{t('backup.history.details.otherFiles', 'Business documents & other')} ({other})
</span>
</div>
<div className="flex items-center space-x-2 pt-1 border-t border-neutral-200 dark:border-neutral-700">
<span className="text-xs uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
{t('backup.history.details.totalFiles', 'Total files')}: {total}
</span>
</div>
</>
);
})()}
</div>
</div>
@@ -0,0 +1,287 @@
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';
// Locale-aware formatters per [[feedback_respect_general_format_settings]].
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
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 { formatDateTime } = useLocalizedDate();
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: formatDateTime(new Date(report.scannedAt)),
})}
</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>
);
};
+134 -10
View File
@@ -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);
@@ -337,15 +338,68 @@ export const RestoreWizard = () => {
</p>
</div>
</div>
{backup.encrypted && (
<Shield className="h-5 w-5 text-neutral-400" />
)}
<div className="flex items-center space-x-2">
{/* Files-only warning — backend's /list-backups now
returns `database_included: boolean` parsed from
the manifest's database.backup_file field. A row
where this is false is exactly the data-loss
scenario the Stage A guard prevents going forward:
a manifest written without an inline DB dump.
Restoring it would NOT bring CRM data back. */}
{backup.database_included === false && (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300 border border-red-300 dark:border-red-700"
title={t('backup.restore.backup.filesOnlyHint',
'This backup has no database dump — restoring it will NOT recover the database (CRM data, customers, quotes, invoices, contracts will be empty after restore).')}
>
<AlertCircle className="h-3 w-3" />
{t('backup.restore.backup.filesOnlyBadge', 'No DB')}
</span>
)}
{backup.corrupt && (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300 border border-amber-300 dark:border-amber-700"
title={t('backup.restore.backup.corruptHint',
'The manifest file is unreadable — the backup may be incomplete or damaged.')}
>
<AlertCircle className="h-3 w-3" />
{t('backup.restore.backup.corruptBadge', 'Corrupt')}
</span>
)}
{backup.encrypted && (
<Shield className="h-5 w-5 text-neutral-400" />
)}
</div>
</div>
</Card>
))}
</div>
)}
{/* Files-only callout below the selected card. Reinforces the
badge with a longer explanation + reminds the admin that
restoring this WILL still proceed — they just won't get the
DB back. Stops the silent-failure class that originally
caused Ralf's 2026-05-29 data loss (four files-only manifests
mistaken for full backups). */}
{restoreData.selectedBackup && restoreData.selectedBackup.database_included === false && (
<Card className="p-4 bg-red-50 dark:bg-red-900/30 border-red-300 dark:border-red-700">
<div className="flex items-start space-x-3">
<AlertCircle className="h-5 w-5 text-red-600 dark:text-red-400 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-semibold text-red-800 dark:text-red-200">
{t('backup.restore.backup.filesOnlyWarning.title',
'Selected backup has no database dump')}
</p>
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
{t('backup.restore.backup.filesOnlyWarning.message',
'Restoring this backup will recover files (photos, PDFs) but the database — including admin users, customers, quotes, invoices, contracts, and settings — will NOT come back. Pick a different backup if you have one with a database dump, or proceed only if files-only is what you want.')}
</p>
</div>
</div>
</Card>
)}
{restoreData.selectedBackup?.encrypted && (
<Card className="p-4 bg-amber-50 dark:bg-amber-900/30 border-amber-200 dark:border-amber-800">
<div className="flex items-start space-x-3">
@@ -573,16 +627,63 @@ export const RestoreWizard = () => {
const renderProgress = () => {
const progress = restoreStatus?.currentProgress || {};
const isRunning = restoreStatus?.isRunning;
// Pull the most recent restore_runs row from history so we can
// tell whether the "not running" state means success, failure, or
// never-started. The history endpoint already returns rows newest
// first.
const lastRun = restoreStatus?.history?.[0];
const lastRunFailed =
!isRunning && lastRun && (lastRun.status === 'failed' || lastRun.was_successful === false);
const lastRunSucceeded =
!isRunning && lastRun && lastRun.status === 'completed' && lastRun.was_successful === true;
// Strip the noisy stack-trace tail from the error message so the
// user sees the actionable line first.
const lastRunError = lastRun?.error_message
? lastRun.error_message.split('\n')[0].slice(0, 500)
: null;
const subtitle = isRunning
? t('backup.restore.progress.inProgress')
: lastRunFailed
? t('backup.restore.progress.failedSubtitle', 'Restore failed — see error below. Destination has been rolled back to its pre-restore state.')
: lastRunSucceeded
? t('backup.restore.progress.completed')
: t('backup.restore.progress.idle', 'No restore in progress.');
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2">{t('backup.restore.progress.title')}</h3>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
{isRunning ? t('backup.restore.progress.inProgress') : t('backup.restore.progress.completed')}
<p className={`text-sm ${
lastRunFailed
? 'text-red-700 dark:text-red-300 font-medium'
: 'text-neutral-600 dark:text-neutral-400'
}`}>
{subtitle}
</p>
</div>
{lastRunFailed && (
<div className="bg-red-50 dark:bg-red-900/30 border border-red-300 dark:border-red-700 rounded-lg p-4">
<div className="flex items-start gap-3">
<XCircle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h4 className="text-sm font-semibold text-red-800 dark:text-red-200 mb-1">
{t('backup.restore.progress.errorTitle', 'Restore did not complete')}
</h4>
<p className="text-sm text-red-700 dark:text-red-300 font-mono break-all">
{lastRunError || t('backup.restore.progress.errorUnknown', 'No error message recorded.')}
</p>
{lastRun.was_rollback_attempted && (
<p className="mt-2 text-xs text-red-600 dark:text-red-400">
{t('backup.restore.progress.rolledBack',
'Pre-restore safety backup was used to roll back. Destination is in its pre-restore state — safe to retry once the issue above is resolved.')}
</p>
)}
</div>
</div>
</div>
)}
{/* Progress Bar */}
<Card className="p-6">
<div className="space-y-4">
@@ -645,18 +746,41 @@ export const RestoreWizard = () => {
</Card>
)}
{/* Completion Actions */}
{!isRunning && progress.status === 'completed' && (
{/* Completion Actions — only when the most recent run actually
succeeded. Previously this gated on `progress.status` which
could be null between runs, so the green "Restore completed
successfully" banner could render alongside a silent failure. */}
{lastRunSucceeded && (
<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>
+84 -3
View File
@@ -231,7 +231,85 @@
"dashboard": "Dashboard",
"configuration": "Konfiguration",
"history": "Backup-Verlauf",
"restore": "Wiederherstellung"
"restore": "Wiederherstellung",
"integrity": "Integrität",
"coverage": "Abdeckung"
},
"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"
}
},
"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": {
"inProgress": "Backup läuft...",
@@ -398,7 +476,9 @@
"completed": "Abgeschlossen",
"contentBackedUp": "Gesicherter Inhalt",
"errorDetails": "Fehlerdetails",
"manifest": "Manifest"
"manifest": "Manifest",
"otherFiles": "Geschäftsdokumente & sonstige",
"totalFiles": "Gesamtdateien"
},
"pagination": {
"showing": "Zeige {{from}}-{{to}} von {{total}} Backups",
@@ -518,7 +598,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": {
+84 -3
View File
@@ -2174,7 +2174,85 @@
"dashboard": "Dashboard",
"configuration": "Configuration",
"history": "Backup History",
"restore": "Restore"
"restore": "Restore",
"integrity": "Integrity",
"coverage": "Coverage"
},
"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"
}
},
"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": {
"inProgress": "Backup in progress...",
@@ -2341,7 +2419,9 @@
"completed": "Completed",
"contentBackedUp": "Content Backed Up",
"errorDetails": "Error Details",
"manifest": "Manifest"
"manifest": "Manifest",
"otherFiles": "Business documents & other",
"totalFiles": "Total files"
},
"pagination": {
"showing": "Showing {{from}}-{{to}} of {{total}} backups",
@@ -2461,7 +2541,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": {
+16 -2
View File
@@ -10,6 +10,8 @@ import {
Clock,
Loader2,
Shield,
ShieldCheck,
FolderTree,
} from 'lucide-react';
import { toast } from 'react-toastify';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
@@ -21,9 +23,11 @@ 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 { BackupCoverageCard } from '../../components/admin/BackupCoverageCard';
import { api } from '../../config/api';
type TabId = 'dashboard' | 'configuration' | 'history' | 'restore';
type TabId = 'dashboard' | 'configuration' | 'history' | 'restore' | 'integrity' | 'coverage';
export const BackupManagement: React.FC = () => {
const [activeTab, setActiveTab] = useState<TabId>('dashboard');
@@ -35,6 +39,8 @@ 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 },
{ id: 'coverage' as const, label: t('backup.tabs.coverage', 'Coverage'), icon: FolderTree },
];
const { data: backupStatus, isLoading: statusLoading } = useQuery({
@@ -218,7 +224,15 @@ export const BackupManagement: React.FC = () => {
)}
{activeTab === 'restore' && (
<RestoreWizard />
<RestoreWizard onVerifyIntegrity={() => setActiveTab('integrity')} />
)}
{activeTab === 'integrity' && (
<BackupIntegrityCard />
)}
{activeTab === 'coverage' && (
<BackupCoverageCard />
)}
</div>
</div>
+119
View File
@@ -201,6 +201,102 @@ 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[];
}
// ---- 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 {
id: number;
username: string;
@@ -262,6 +358,29 @@ 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;
},
// 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
formatActivityMessage(activity: Activity): string {
// Feature-flag toggles carry a `changed` diff in metadata. Render