This commit is contained in:
seb
2026-10-14 00:47:36 +02:00
parent c0600ecb3b
commit 2dd6533481
13 changed files with 1371 additions and 8 deletions

View File

@@ -0,0 +1,567 @@
import { Component } from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Chip from '@mui/material/Chip';
import CircularProgress from '@mui/material/CircularProgress';
import Collapse from '@mui/material/Collapse';
import Divider from '@mui/material/Divider';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
import Tab from '@mui/material/Tab';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Tabs from '@mui/material/Tabs';
import Typography from '@mui/material/Typography';
import BackupOutlinedIcon from '@mui/icons-material/BackupOutlined';
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutlined';
import CloudQueueOutlinedIcon from '@mui/icons-material/CloudQueueOutlined';
import FolderOutlinedIcon from '@mui/icons-material/FolderOutlined';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import PlayArrowOutlinedIcon from '@mui/icons-material/PlayArrowOutlined';
import RefreshIcon from '@mui/icons-material/Refresh';
import StorageIcon from '@mui/icons-material/Storage';
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import { enqueueSnackbar } from 'notistack';
import backupAutomat from '../../automats/backupAutomat';
import { notifyError } from '../../utils/notify';
import {
BORDER,
CHROMA,
DARK_1,
DARK_2,
DARK_4,
LIGHT_3,
PRIMARY,
SURFACE_BASE,
SURFACE_RAISED,
WHITE,
} from '../../colors';
function formatBytes(bytes) {
if (bytes == null || isNaN(bytes) || bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
}
function formatDate(ts) {
if (!ts) return '—';
try {
const d = new Date(ts);
if (isNaN(d.getTime())) return String(ts);
return d.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
} catch {
return String(ts);
}
}
function formatDuration(ms) {
if (ms == null || isNaN(ms)) return '—';
if (ms < 1000) return `${ms} ms`;
return `${(ms / 1000).toFixed(2)} s`;
}
export default class BackupSettingsPanel extends Component {
constructor(props) {
super(props);
const { status, error, runningBackup, backupStatus, backups } = backupAutomat.state;
this.state = {
status,
error,
runningBackup,
backupStatus,
backups,
tierTab: 'hourly', // 'hourly' | 'daily' | 'monthly'
expandedItemName: null,
};
this.handleRefresh = this.handleRefresh.bind(this);
this.handleRunBackup = this.handleRunBackup.bind(this);
this.handleTierChange = this.handleTierChange.bind(this);
this.handleToggleExpand = this.handleToggleExpand.bind(this);
}
componentDidMount() {
this.unsub = backupAutomat.subscribe(this, (state) => ({
status: state.status,
error: state.error,
runningBackup: state.runningBackup,
backupStatus: state.backupStatus,
backups: state.backups,
}));
if (backupAutomat.state.status === 'idle') {
void this.loadBackups();
}
}
componentWillUnmount() {
this.unsub?.();
}
// #region loadBackups
// MARK: calls backupAutomat.actions.load from ./api/index.js
async loadBackups() {
try {
await backupAutomat.actions.load();
} catch (err) {
notifyError(err);
}
}
// #endregion
// #region handleRefresh
// MARK: calls backupAutomat.actions.reload from ./api/index.js
async handleRefresh() {
try {
await backupAutomat.actions.reload();
enqueueSnackbar('Backup-Status aktualisiert', { variant: 'info' });
} catch (err) {
notifyError(err);
}
}
// #endregion
// #region handleRunBackup
// MARK: calls POST /backup/run via backupAutomat.actions.runBackup from ./api/index.js
async handleRunBackup() {
if (this.state.runningBackup || this.state.backupStatus?.isRunning) return;
try {
const result = await backupAutomat.actions.runBackup();
const sizeStr = result?.totalSizeBytes != null ? ` (${formatBytes(result.totalSizeBytes)})` : '';
enqueueSnackbar(`Manuelles Backup erfolgreich erstellt${sizeStr}.`, {
variant: 'success',
});
} catch (err) {
notifyError(err);
}
}
// #endregion
handleTierChange(_event, newValue) {
this.setState({ tierTab: newValue, expandedItemName: null });
}
handleToggleExpand(name) {
this.setState((prev) => ({
expandedItemName: prev.expandedItemName === name ? null : name,
}));
}
render() {
const { status, error, runningBackup, backupStatus, backups, tierTab, expandedItemName } = this.state;
if (status === 'loading' && !backupStatus) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 8, gap: 2 }}>
<CircularProgress size={36} />
<Typography variant="body2" color="text.secondary">
Backup-Status wird geladen...
</Typography>
</Box>
);
}
if (status === 'failed' && !backupStatus) {
return (
<Stack spacing={2} sx={{ py: 2 }}>
<Alert severity="error">
{error || 'Fehler beim Laden des Backup-Status.'}
</Alert>
<Button
variant="outlined"
startIcon={<RefreshIcon />}
onClick={this.handleRefresh}
sx={{ alignSelf: 'flex-start' }}
>
Erneut versuchen
</Button>
</Stack>
);
}
const isExecuting = Boolean(runningBackup || backupStatus?.isRunning);
const retention = backupStatus?.retention ?? { hourlyMax: 24, dailyMax: 7, monthly: 'Monatserster' };
const counts = backups?.counts ?? { hourly: 0, daily: 0, monthly: 0 };
const hourlyList = backups?.hourly || [];
const dailyList = backups?.daily || [];
const monthlyList = backups?.monthly || [];
const activeList = tierTab === 'hourly'
? hourlyList
: tierTab === 'daily'
? dailyList
: monthlyList;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* Header Title & Actions */}
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', flexWrap: 'wrap', gap: 2 }}>
<Box>
<Typography variant="h6" fontWeight={700} sx={{ color: DARK_4, display: 'flex', alignItems: 'center', gap: 1 }}>
<BackupOutlinedIcon sx={{ color: PRIMARY }} />
Datensicherung & Backups
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
Automatische Sicherung von Datenbank, virtuellen Dateisystemen (VFS) und Dokumentenspeicher.
</Typography>
</Box>
<Stack direction="row" spacing={1.5} alignItems="center">
<Button
variant="outlined"
size="small"
startIcon={<RefreshIcon />}
onClick={this.handleRefresh}
disabled={isExecuting}
>
Aktualisieren
</Button>
<Button
variant="contained"
size="small"
startIcon={isExecuting ? <CircularProgress size={16} color="inherit" /> : <PlayArrowOutlinedIcon />}
onClick={this.handleRunBackup}
disabled={isExecuting}
sx={{ bgcolor: PRIMARY, '&:hover': { bgcolor: CHROMA.PETROL.dark } }}
>
{isExecuting ? 'Sicherung läuft...' : 'Jetzt sichern'}
</Button>
</Stack>
</Box>
{/* Status Badges Bar */}
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: 1.5,
p: 1.5,
borderRadius: 2,
bgcolor: SURFACE_BASE,
border: `1px solid ${BORDER}`,
}}
>
<Chip
size="small"
icon={backupStatus?.enabled ? <CheckCircleOutlineIcon /> : <WarningAmberIcon />}
label={backupStatus?.enabled ? 'Dienst: Aktiv' : 'Dienst: Deaktiviert'}
color={backupStatus?.enabled ? 'success' : 'default'}
variant="outlined"
/>
{isExecuting && (
<Chip
size="small"
icon={<CircularProgress size={12} color="inherit" />}
label="Backup wird ausgeführt..."
color="primary"
/>
)}
<Chip
size="small"
icon={<StorageIcon />}
label={`Provider: ${backupStatus?.dbProvider?.toUpperCase() || 'DATENBANK'}`}
variant="outlined"
/>
{backupStatus?.backupDir && (
<Chip
size="small"
icon={<FolderOutlinedIcon />}
label={`Verzeichnis: ${backupStatus.backupDir}`}
variant="outlined"
sx={{ maxWidth: 360 }}
/>
)}
</Box>
{/* MSSQL Warning if present */}
{backupStatus?.mssqlWarning && (
<Alert severity="warning" icon={<WarningAmberIcon />}>
MSSQL-Hinweis: Datenbank-Backups erfordern Schreibberechtigungen des SQL-Server-Dienstes auf das Backup-Verzeichnis oder werden getrennt verwaltet.
</Alert>
)}
{/* Overview KPI Cards */}
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', sm: 'repeat(3, 1fr)' },
gap: 2,
}}
>
{/* Hourly KPI */}
<Card variant="outlined" sx={{ bgcolor: WHITE, borderColor: BORDER, borderRadius: 2 }}>
<CardContent sx={{ p: 2, '&:last-child': { pb: 2 } }}>
<Typography variant="caption" color="text.secondary" fontWeight={600} textTransform="uppercase">
Stündliche Sicherungen
</Typography>
<Typography variant="h5" fontWeight={700} sx={{ color: DARK_4, mt: 0.5 }}>
{counts.hourly}{' '}
<Typography component="span" variant="body2" color="text.secondary">
/ {retention.hourlyMax} max
</Typography>
</Typography>
<Divider sx={{ my: 1 }} />
<Typography variant="caption" color="text.secondary" display="block">
Zuletzt: {formatDate(backupStatus?.latestHourly?.timestamp || hourlyList[0]?.timestamp)}
</Typography>
<Typography variant="caption" color="text.secondary" display="block">
Größe: {formatBytes(backupStatus?.latestHourly?.totalSizeBytes || hourlyList[0]?.totalSizeBytes)}
</Typography>
</CardContent>
</Card>
{/* Daily KPI */}
<Card variant="outlined" sx={{ bgcolor: WHITE, borderColor: BORDER, borderRadius: 2 }}>
<CardContent sx={{ p: 2, '&:last-child': { pb: 2 } }}>
<Typography variant="caption" color="text.secondary" fontWeight={600} textTransform="uppercase">
Tägliche Sicherungen
</Typography>
<Typography variant="h5" fontWeight={700} sx={{ color: DARK_4, mt: 0.5 }}>
{counts.daily}{' '}
<Typography component="span" variant="body2" color="text.secondary">
/ {retention.dailyMax} max
</Typography>
</Typography>
<Divider sx={{ my: 1 }} />
<Typography variant="caption" color="text.secondary" display="block">
Zuletzt: {formatDate(backupStatus?.latestDaily?.timestamp || dailyList[0]?.timestamp)}
</Typography>
<Typography variant="caption" color="text.secondary" display="block">
Größe: {formatBytes(backupStatus?.latestDaily?.totalSizeBytes || dailyList[0]?.totalSizeBytes)}
</Typography>
</CardContent>
</Card>
{/* Monthly KPI */}
<Card variant="outlined" sx={{ bgcolor: WHITE, borderColor: BORDER, borderRadius: 2 }}>
<CardContent sx={{ p: 2, '&:last-child': { pb: 2 } }}>
<Typography variant="caption" color="text.secondary" fontWeight={600} textTransform="uppercase">
Monatliche Sicherungen
</Typography>
<Typography variant="h5" fontWeight={700} sx={{ color: DARK_4, mt: 0.5 }}>
{counts.monthly}
</Typography>
<Divider sx={{ my: 1 }} />
<Typography variant="caption" color="text.secondary" display="block">
Zuletzt: {formatDate(backupStatus?.latestMonthly?.timestamp || monthlyList[0]?.timestamp)}
</Typography>
<Typography variant="caption" color="text.secondary" display="block">
Größe: {formatBytes(backupStatus?.latestMonthly?.totalSizeBytes || monthlyList[0]?.totalSizeBytes)}
</Typography>
</CardContent>
</Card>
</Box>
{/* Retention Policy Note */}
<Typography variant="caption" color="text.secondary">
Aufbewahrungsrichtlinie: Maximal {retention.hourlyMax} stündliche Snapshots, {retention.dailyMax} tägliche Snapshots; monatliche Archivierung ({retention.monthly || 'am 1. Tag des Monats'}). Ältere Snapshots werden automatisch bereinigt.
</Typography>
{/* Backups Table Section with Tabs */}
<Box sx={{ border: `1px solid ${BORDER}`, borderRadius: 2, bgcolor: WHITE, overflow: 'hidden' }}>
<Box sx={{ borderBottom: `1px solid ${BORDER}`, bgcolor: SURFACE_RAISED, px: 2 }}>
<Tabs
value={tierTab}
onChange={this.handleTierChange}
textColor="primary"
indicatorColor="primary"
sx={{ minHeight: 44 }}
>
<Tab
value="hourly"
label={`Stündlich (${counts.hourly})`}
sx={{ minHeight: 44, textTransform: 'none', fontWeight: 600 }}
/>
<Tab
value="daily"
label={`Täglich (${counts.daily})`}
sx={{ minHeight: 44, textTransform: 'none', fontWeight: 600 }}
/>
<Tab
value="monthly"
label={`Monatlich (${counts.monthly})`}
sx={{ minHeight: 44, textTransform: 'none', fontWeight: 600 }}
/>
</Tabs>
</Box>
{activeList.length === 0 ? (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 6, gap: 1 }}>
<CloudQueueOutlinedIcon sx={{ fontSize: 40, color: DARK_1 }} />
<Typography variant="body2" color="text.secondary">
Keine Sicherungen in dieser Kategorie vorhanden.
</Typography>
</Box>
) : (
<TableContainer sx={{ maxHeight: 360 }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell sx={{ width: 40 }} />
<TableCell sx={{ fontWeight: 600 }}>Zeitpunkt</TableCell>
<TableCell sx={{ fontWeight: 600 }}>Archivname</TableCell>
<TableCell sx={{ fontWeight: 600 }}>Größe</TableCell>
<TableCell sx={{ fontWeight: 600 }}>Typ</TableCell>
</TableRow>
</TableHead>
<TableBody>
{activeList.map((item) => {
const isExpanded = expandedItemName === item.name;
const manifest = item.manifest;
const isManual = Boolean(manifest?.manual);
return (
<BackupTableRow
key={item.name}
item={item}
isExpanded={isExpanded}
isManual={isManual}
manifest={manifest}
onToggleExpand={() => this.handleToggleExpand(item.name)}
/>
);
})}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
</Box>
);
}
}
function BackupTableRow({ item, isExpanded, isManual, manifest, onToggleExpand }) {
return (
<>
<TableRow
hover
onClick={onToggleExpand}
sx={{
cursor: 'pointer',
bgcolor: isExpanded ? LIGHT_3 : 'inherit',
'& > *': { borderBottom: isExpanded ? 'none !important' : undefined },
}}
>
<TableCell sx={{ py: 1 }}>
<IconButton size="small" onClick={(e) => { e.stopPropagation(); onToggleExpand(); }}>
{isExpanded ? <KeyboardArrowUpIcon fontSize="small" /> : <KeyboardArrowDownIcon fontSize="small" />}
</IconButton>
</TableCell>
<TableCell sx={{ py: 1, fontWeight: 500 }}>
{formatDate(item.timestamp)}
</TableCell>
<TableCell sx={{ py: 1, color: DARK_2, fontFamily: 'monospace', fontSize: '0.8rem' }}>
{item.name}
</TableCell>
<TableCell sx={{ py: 1, fontWeight: 600 }}>
{formatBytes(item.totalSizeBytes)}
</TableCell>
<TableCell sx={{ py: 1 }}>
<Chip
size="small"
label={isManual ? 'Manuell' : 'Automatisch'}
color={isManual ? 'primary' : 'default'}
variant="outlined"
sx={{ height: 22, fontSize: '0.75rem' }}
/>
</TableCell>
</TableRow>
<TableRow sx={{ bgcolor: LIGHT_3 }}>
<TableCell colSpan={5} sx={{ py: 0, px: 3 }}>
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
<Box sx={{ py: 2 }}>
<Typography variant="caption" fontWeight={700} color="text.secondary" textTransform="uppercase">
Backup-Details & Manifest
</Typography>
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', sm: 'repeat(3, 1fr)' },
gap: 1.5,
mt: 1,
p: 1.5,
borderRadius: 1.5,
bgcolor: WHITE,
border: `1px solid ${BORDER}`,
}}
>
<Box>
<Typography variant="caption" color="text.secondary" display="block">
Datenbank
</Typography>
<Typography variant="body2" fontWeight={600}>
{manifest?.files?.database
? `${formatBytes(manifest.files.database.sizeBytes)} (${manifest.files.database.backedUp ? 'OK' : manifest.files.database.reason || 'Übersprungen'})`
: '—'}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary" display="block">
VFS (Dateien & Belege)
</Typography>
<Typography variant="body2" fontWeight={600}>
{manifest?.files?.vfs ? formatBytes(manifest.files.vfs.sizeBytes) : '—'}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary" display="block">
Dokumentenspeicher (Storage)
</Typography>
<Typography variant="body2" fontWeight={600}>
{manifest?.files?.storage ? formatBytes(manifest.files.storage.sizeBytes) : '—'}
</Typography>
</Box>
{manifest?.durationMs != null && (
<Box>
<Typography variant="caption" color="text.secondary" display="block">
Dauer
</Typography>
<Typography variant="body2" fontWeight={600}>
{formatDuration(manifest.durationMs)}
</Typography>
</Box>
)}
{manifest?.dbProvider && (
<Box>
<Typography variant="caption" color="text.secondary" display="block">
Datenbank-Provider
</Typography>
<Typography variant="body2" fontWeight={600}>
{manifest.dbProvider.toUpperCase()}
</Typography>
</Box>
)}
<Box sx={{ gridColumn: { sm: 'span 3' } }}>
<Typography variant="caption" color="text.secondary" display="block">
Speicherpfad
</Typography>
<Typography variant="caption" sx={{ fontFamily: 'monospace', color: DARK_2, wordBreak: 'break-all' }}>
{item.path}
</Typography>
</Box>
</Box>
</Box>
</Collapse>
</TableCell>
</TableRow>
</>
);
}

View File

@@ -20,7 +20,9 @@ import SettingsKeysPanel from './SettingsKeysPanel';
import ImportExportSettingsPanel from './ImportExportSettingsPanel';
import PosTerminalSettingsPanel from './PosTerminalSettingsPanel';
import McpAuthorizationsPanel from './McpAuthorizationsPanel';
import BackupSettingsPanel from './BackupSettingsPanel';
import SecurityIcon from '@mui/icons-material/Security';
import BackupTableOutlinedIcon from '@mui/icons-material/BackupTableOutlined';
import { SURFACE_BASE } from '../../colors';
const TAB_KEYS = {
@@ -29,6 +31,7 @@ const TAB_KEYS = {
numbers: 'numbers',
printers: 'printers',
importExport: 'importExport',
backup: 'backup',
posterminal: 'posterminal',
security: 'security',
};
@@ -39,6 +42,7 @@ const TABS = [
{ id: TAB_KEYS.numbers, label: 'Nummernkreise', icon: <NumbersIcon />, Panel: NumberSettingsPanel },
{ id: TAB_KEYS.printers, label: 'Drucker', icon: <PrintOutlinedIcon />, Panel: PrinterSettingsPanel },
{ id: TAB_KEYS.importExport, label: 'Import/Export', icon: <ImportExportIcon />, Panel: ImportExportSettingsPanel },
{ id: TAB_KEYS.backup, label: 'Backups', icon: <BackupTableOutlinedIcon />, Panel: BackupSettingsPanel },
{ id: TAB_KEYS.posterminal, label: 'POS Terminal', icon: <PointOfSaleIcon />, Panel: PosTerminalSettingsPanel },
{ id: TAB_KEYS.security, label: 'Sicherheit & MCP', icon: <SecurityIcon />, Panel: McpAuthorizationsPanel },
];

View File

@@ -5,3 +5,5 @@ export { default as NumberSettingsPanel } from './NumberSettingsPanel';
export { default as PrinterSettingsPanel } from './PrinterSettingsPanel';
export { default as PosTerminalSettingsPanel } from './PosTerminalSettingsPanel';
export { default as ImportExportSettingsPanel } from './ImportExportSettingsPanel';
export { default as McpAuthorizationsPanel } from './McpAuthorizationsPanel';
export { default as BackupSettingsPanel } from './BackupSettingsPanel';