This commit is contained in:
seb
2026-10-14 03:15:43 +02:00
parent 61076a9ef2
commit e7b3044903
3 changed files with 395 additions and 173 deletions

View File

@@ -1,7 +1,7 @@
import { http } from './http.js';
/**
* Backup API for listing backups, checking service status, and triggering manual backups.
* Backup API for listing backups, checking service status, inspecting archived VFS files, and recovering VFS files.
*/
export const backup = {
/**
@@ -16,16 +16,36 @@ export const backup = {
// #endregion
/**
* POST /api/backup/run
* Triggers a manual backup run.
* Returns: 200 { success: bool, id: str, timestamp: str, promotedDaily: bool, promotedMonthly: bool, totalSizeBytes: int, pruneResult: { hourlyPruned: int, dailyPruned: int }, manifest: object }
* GET /api/backup/:id/vfs
* Lists VFS files archived in a specific backup for a company.
* @param {string} id Backup identifier (e.g. backup-2026-10-14_02-00-00)
* @param {number} [companyId] Optional company ID (defaults to active company)
* @returns {Promise<{ backupId: string, companyId: number, files: Array<{ name: string, size: number, mime: string, mtime: string }> }>}
*/
// #region POST /api/backup/run — run
// MARK: calls POST /api/backup/run from ./api/index.js
run() {
return http('/api/backup/run', {
// #region GET /api/backup/{id}/vfs — listVfs
// MARK: calls GET /api/backup/:id/vfs from ./api/index.js
listVfs(id, companyId) {
const q = companyId != null ? `?companyId=${encodeURIComponent(companyId)}` : '';
return http(`/api/backup/${encodeURIComponent(id)}/vfs${q}`);
},
// #endregion
/**
* POST /api/backup/:id/vfs/recover
* Safely extracts and restores a VFS file from the specified backup into the active VFS.
* @param {string} id Backup identifier
* @param {string} path Relative file path within the VFS archive
* @param {number} [companyId] Optional company ID
* @returns {Promise<{ success: boolean, backupId: string, companyId: number, path: string, sizeBytes: number, mime: string }>}
*/
// #region POST /api/backup/{id}/vfs/recover — recoverVfs
// MARK: calls POST /api/backup/:id/vfs/recover from ./api/index.js
recoverVfs(id, path, companyId) {
return http(`/api/backup/${encodeURIComponent(id)}/vfs/recover`, {
method: 'POST',
body: JSON.stringify({ path, companyId }),
});
},
// #endregion
};

View File

@@ -1,12 +1,12 @@
/**
* @file backupAutomat.js
* @description Manages backup status and backup archives (`GET /backup`, `POST /backup/run`).
* @description Manages backup status, archive listings, and VFS file recovery from backup archives (`GET /backup`, `GET /backup/:id/vfs`, `POST /backup/:id/vfs/recover`).
*
* Upstream Triggers:
* - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on
* `'authed'` with a `selectedCompanyId`, and `actions.clear()` on `'anon'`.
* - `sseAutomat`: Subscribes to `'backup'` event (if dispatched) to reload silently.
* - UI / Settings: Calls to `actions.load()`, `actions.reload()`, or `actions.runBackup()`.
* - UI / Settings: Calls to `actions.load()`, `actions.reload()`, `actions.listBackupVfs()`, or `actions.recoverBackupVfs()`.
*/
import { Automat } from 'react-automat';
@@ -41,17 +41,16 @@ import sseAutomat from './sseAutomat';
* @typedef {Object} BackupState
* @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state
* @property {string|null} error Last error message
* @property {boolean} runningBackup Whether a manual backup run is currently in progress
* @property {BackupStatus|null} backupStatus Service status and retention settings
* @property {{ counts: { hourly: number, daily: number, monthly: number }, hourly: BackupItem[], daily: BackupItem[], monthly: BackupItem[] }} backups Grouped backup archives
* @property {object|null} lastRunResult Manifest/result from the most recent manual backup run
* @property {Record<string, Array<{ name: string, size: number, mime: string, mtime: string }>>} vfsFilesByBackup
* @property {Record<string, boolean>} vfsLoadingByBackup
*/
/** @type {BackupState} */
const initialState = {
status: 'idle',
error: null,
runningBackup: false,
backupStatus: null,
backups: {
counts: { hourly: 0, daily: 0, monthly: 0 },
@@ -59,7 +58,8 @@ const initialState = {
daily: [],
monthly: [],
},
lastRunResult: null,
vfsFilesByBackup: {},
vfsLoadingByBackup: {},
};
let backupAutomat;
@@ -141,30 +141,50 @@ const actions = {
},
// #endregion
// #region runBackup
// MARK: calls POST /api/backup/run from ./api/index.js
// #region listBackupVfs
// MARK: calls GET /api/backup/:id/vfs from ./api/index.js
/**
* Triggers an immediate manual backup run on the backend.
* Retrieves the list of VFS files stored inside a specific backup.
*
* @returns {Promise<object>} The backup run result containing manifest and pruneResult
* @param {string} backupId Identifier of the backup
* @param {number} [companyId] Optional company ID override
* @returns {Promise<Array<{ name: string, size: number, mime: string, mtime: string }>>}
*/
async runBackup() {
backupAutomat.setState({ runningBackup: true, error: null });
async listBackupVfs(backupId, companyId) {
if (!backupId) return [];
backupAutomat.setState({
vfsLoadingByBackup: { ...backupAutomat.state.vfsLoadingByBackup, [backupId]: true },
});
try {
const result = await backupApi.run();
backupAutomat.setState({ lastRunResult: result });
await actions.reload();
return result;
} catch (err) {
const error = err?.message ?? String(err);
backupAutomat.setState({ error });
throw err;
const res = await backupApi.listVfs(backupId, companyId);
const files = Array.isArray(res?.files) ? res.files : [];
backupAutomat.setState({
vfsFilesByBackup: { ...backupAutomat.state.vfsFilesByBackup, [backupId]: files },
});
return files;
} finally {
backupAutomat.setState({ runningBackup: false });
backupAutomat.setState({
vfsLoadingByBackup: { ...backupAutomat.state.vfsLoadingByBackup, [backupId]: false },
});
}
},
// #endregion
// #region recoverBackupVfs
// MARK: calls POST /api/backup/:id/vfs/recover from ./api/index.js
/**
* Restores a single VFS file from the specified backup into the active VFS.
*
* @param {string} backupId Identifier of the backup
* @param {string} path Relative file path within the VFS
* @param {number} [companyId] Optional company ID override
* @returns {Promise<object>} Recovery result
*/
async recoverBackupVfs(backupId, path, companyId) {
return backupApi.recoverVfs(backupId, path, companyId);
},
// #endregion
/**
* Resets the automat state to initial empty values.
*/

View File

@@ -9,6 +9,7 @@ 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 InputAdornment from '@mui/material/InputAdornment';
import Stack from '@mui/material/Stack';
import Tab from '@mui/material/Tab';
import Table from '@mui/material/Table';
@@ -18,15 +19,18 @@ 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 TextField from '@mui/material/TextField';
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 InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined';
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 SearchIcon from '@mui/icons-material/Search';
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
import StorageIcon from '@mui/icons-material/Storage';
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import { enqueueSnackbar } from 'notistack';
@@ -34,7 +38,6 @@ import backupAutomat from '../../automats/backupAutomat';
import { notifyError } from '../../utils/notify';
import {
BORDER,
CHROMA,
DARK_1,
DARK_2,
DARK_4,
@@ -79,19 +82,19 @@ function formatDuration(ms) {
export default class BackupSettingsPanel extends Component {
constructor(props) {
super(props);
const { status, error, runningBackup, backupStatus, backups } = backupAutomat.state;
const { status, error, backupStatus, backups, vfsFilesByBackup, vfsLoadingByBackup } = backupAutomat.state;
this.state = {
status,
error,
runningBackup,
backupStatus,
backups,
vfsFilesByBackup: vfsFilesByBackup || {},
vfsLoadingByBackup: vfsLoadingByBackup || {},
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);
}
@@ -100,9 +103,10 @@ export default class BackupSettingsPanel extends Component {
this.unsub = backupAutomat.subscribe(this, (state) => ({
status: state.status,
error: state.error,
runningBackup: state.runningBackup,
backupStatus: state.backupStatus,
backups: state.backups,
vfsFilesByBackup: state.vfsFilesByBackup,
vfsLoadingByBackup: state.vfsLoadingByBackup,
}));
if (backupAutomat.state.status === 'idle') {
@@ -137,23 +141,6 @@ export default class BackupSettingsPanel extends Component {
}
// #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 });
}
@@ -165,7 +152,16 @@ export default class BackupSettingsPanel extends Component {
}
render() {
const { status, error, runningBackup, backupStatus, backups, tierTab, expandedItemName } = this.state;
const {
status,
error,
backupStatus,
backups,
vfsFilesByBackup,
vfsLoadingByBackup,
tierTab,
expandedItemName,
} = this.state;
if (status === 'loading' && !backupStatus) {
return (
@@ -196,7 +192,7 @@ export default class BackupSettingsPanel extends Component {
);
}
const isExecuting = Boolean(runningBackup || backupStatus?.isRunning);
const isRunning = Boolean(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 || [];
@@ -219,7 +215,7 @@ export default class BackupSettingsPanel extends Component {
Datensicherung & Backups
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
Automatische Sicherung von Datenbank, virtuellen Dateisystemen (VFS) und Dokumentenspeicher.
Automatische Sicherung von Datenbank, virtuellem Dateisystem (VFS) und Dokumentenspeicher.
</Typography>
</Box>
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}>
@@ -228,20 +224,10 @@ export default class BackupSettingsPanel extends Component {
size="small"
startIcon={<RefreshIcon />}
onClick={this.handleRefresh}
disabled={isExecuting}
disabled={isRunning}
>
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>
@@ -265,7 +251,7 @@ export default class BackupSettingsPanel extends Component {
color={backupStatus?.enabled ? 'success' : 'default'}
variant="outlined"
/>
{isExecuting && (
{isRunning && (
<Chip
size="small"
icon={<CircularProgress size={12} color="inherit" />}
@@ -410,7 +396,7 @@ export default class BackupSettingsPanel extends Component {
</Typography>
</Box>
) : (
<TableContainer sx={{ maxHeight: 360 }}>
<TableContainer sx={{ maxHeight: 480 }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
@@ -418,22 +404,24 @@ export default class BackupSettingsPanel extends Component {
<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>
<TableCell sx={{ fontWeight: 600 }}>VFS-Dateien</TableCell>
</TableRow>
</TableHead>
<TableBody>
{activeList.map((item) => {
const isExpanded = expandedItemName === item.name;
const manifest = item.manifest;
const isManual = Boolean(manifest?.manual);
const vfsFiles = vfsFilesByBackup[item.name];
const vfsLoading = Boolean(vfsLoadingByBackup[item.name]);
return (
<BackupTableRow
key={item.name}
item={item}
isExpanded={isExpanded}
isManual={isManual}
manifest={manifest}
vfsFiles={vfsFiles}
vfsLoading={vfsLoading}
onToggleExpand={() => this.handleToggleExpand(item.name)}
/>
);
@@ -448,120 +436,314 @@ export default class BackupSettingsPanel extends Component {
}
}
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" sx={{ 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 && (
class BackupTableRow extends Component {
constructor(props) {
super(props);
this.state = {
vfsFilter: '',
recoveringPath: null,
vfsExplorerOpen: false,
};
this.handleToggleVfsExplorer = this.handleToggleVfsExplorer.bind(this);
this.handleRecoverFile = this.handleRecoverFile.bind(this);
}
// #region handleToggleVfsExplorer
// MARK: calls backupAutomat.actions.listBackupVfs from ./api/index.js
async handleToggleVfsExplorer(e) {
e?.stopPropagation?.();
const nextOpen = !this.state.vfsExplorerOpen;
this.setState({ vfsExplorerOpen: nextOpen });
if (nextOpen && !this.props.vfsFiles && !this.props.vfsLoading) {
try {
await backupAutomat.actions.listBackupVfs(this.props.item.name);
} catch (err) {
notifyError(err);
}
}
}
// #endregion
// #region handleRecoverFile
// MARK: calls POST /backup/:id/vfs/recover via backupAutomat.actions.recoverBackupVfs from ./api/index.js
async handleRecoverFile(e, path) {
e?.stopPropagation?.();
if (this.state.recoveringPath) return;
this.setState({ recoveringPath: path });
try {
await backupAutomat.actions.recoverBackupVfs(this.props.item.name, path);
enqueueSnackbar(`Datei "${path}" erfolgreich ins VFS wiederhergestellt.`, {
variant: 'success',
});
} catch (err) {
notifyError(err);
} finally {
this.setState({ recoveringPath: null });
}
}
// #endregion
render() {
const { item, isExpanded, manifest, vfsFiles, vfsLoading, onToggleExpand } = this.props;
const { vfsFilter, recoveringPath, vfsExplorerOpen } = this.state;
const filteredFiles = Array.isArray(vfsFiles)
? vfsFilter.trim()
? vfsFiles.filter((f) => f.name.toLowerCase().includes(vfsFilter.toLowerCase()))
: vfsFiles
: [];
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"
icon={<FolderOutlinedIcon />}
label={manifest?.files?.vfs ? formatBytes(manifest.files.vfs.sizeBytes) : '—'}
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" sx={{ 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">
Dauer
Datenbank
</Typography>
<Typography variant="body2" fontWeight={600}>
{formatDuration(manifest.durationMs)}
{manifest?.files?.database
? `${formatBytes(manifest.files.database.sizeBytes)} (${manifest.files.database.backedUp ? 'OK' : manifest.files.database.reason || 'Übersprungen'})`
: '—'}
</Typography>
</Box>
)}
{manifest?.dbProvider && (
<Box>
<Typography variant="caption" color="text.secondary" display="block">
Datenbank-Provider
VFS (Dateien & Belege)
</Typography>
<Typography variant="body2" fontWeight={600}>
{manifest.dbProvider.toUpperCase()}
{manifest?.files?.vfs ? formatBytes(manifest.files.vfs.sizeBytes) : '—'}
</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>
<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>
{/* VFS File Explorer & Restore Section */}
<Box
sx={{
mt: 2,
p: 1.5,
borderRadius: 1.5,
bgcolor: WHITE,
border: `1px solid ${BORDER}`,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FolderOutlinedIcon sx={{ color: PRIMARY, fontSize: 20 }} />
<Typography variant="subtitle2" fontWeight={700} sx={{ color: DARK_4 }}>
Archivierte VFS-Dateien & Wiederherstellung
</Typography>
{Array.isArray(vfsFiles) && (
<Chip size="small" label={`${vfsFiles.length} Dateien`} variant="outlined" sx={{ height: 20, fontSize: '0.75rem' }} />
)}
</Box>
<Button
size="small"
variant="outlined"
startIcon={vfsLoading ? <CircularProgress size={14} color="inherit" /> : vfsExplorerOpen ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
onClick={this.handleToggleVfsExplorer}
disabled={vfsLoading}
sx={{ textTransform: 'none' }}
>
{vfsExplorerOpen ? 'Dateien verbergen' : 'VFS-Dateien durchsuchen'}
</Button>
</Box>
<Collapse in={vfsExplorerOpen} timeout="auto" unmountOnExit>
<Box sx={{ mt: 1.5 }}>
{vfsLoading ? (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', py: 3, gap: 1.5 }}>
<CircularProgress size={20} />
<Typography variant="body2" color="text.secondary">
VFS-Archiv wird gelesen...
</Typography>
</Box>
) : !vfsFiles || vfsFiles.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
Keine VFS-Dateien in dieser Sicherung für das aktuelle Unternehmen gefunden.
</Typography>
) : (
<Stack spacing={1.5}>
{vfsFiles.length > 3 && (
<TextField
size="small"
placeholder="Dateien filtern..."
value={vfsFilter}
onChange={(e) => this.setState({ vfsFilter: e.target.value })}
onClick={(e) => e.stopPropagation()}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" sx={{ color: DARK_1 }} />
</InputAdornment>
),
},
}}
sx={{ maxWidth: 320 }}
/>
)}
<TableContainer sx={{ maxHeight: 280, border: `1px solid ${BORDER}`, borderRadius: 1 }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell sx={{ fontWeight: 600 }}>Dateipfad im VFS</TableCell>
<TableCell sx={{ fontWeight: 600 }}>Größe</TableCell>
<TableCell sx={{ fontWeight: 600 }}>Typ</TableCell>
<TableCell sx={{ fontWeight: 600 }}>Änderungsdatum</TableCell>
<TableCell align="right" sx={{ fontWeight: 600 }}>Aktion</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filteredFiles.length === 0 ? (
<TableRow>
<TableCell colSpan={5} sx={{ textAlign: 'center', py: 2, color: 'text.secondary' }}>
Keine Dateien entsprechen dem Suchbegriff.
</TableCell>
</TableRow>
) : (
filteredFiles.map((f) => {
const isRecovering = recoveringPath === f.name;
return (
<TableRow key={f.name} hover>
<TableCell sx={{ py: 0.75 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<InsertDriveFileOutlinedIcon fontSize="small" sx={{ color: DARK_1 }} />
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: '0.8rem', wordBreak: 'break-all' }}>
{f.name}
</Typography>
</Box>
</TableCell>
<TableCell sx={{ py: 0.75, whiteSpace: 'nowrap' }}>
{formatBytes(f.size)}
</TableCell>
<TableCell sx={{ py: 0.75, color: 'text.secondary', fontSize: '0.75rem' }}>
{f.mime || '—'}
</TableCell>
<TableCell sx={{ py: 0.75, color: 'text.secondary', fontSize: '0.75rem', whiteSpace: 'nowrap' }}>
{formatDate(f.mtime)}
</TableCell>
<TableCell align="right" sx={{ py: 0.75 }}>
<Button
size="small"
variant="outlined"
color="primary"
startIcon={isRecovering ? <CircularProgress size={12} color="inherit" /> : <SettingsBackupRestoreIcon fontSize="small" />}
onClick={(e) => this.handleRecoverFile(e, f.name)}
disabled={Boolean(recoveringPath)}
sx={{ textTransform: 'none', py: 0.25, px: 1, minHeight: 26, fontSize: '0.75rem' }}
>
{isRecovering ? 'Wiederherstellen...' : 'Wiederherstellen'}
</Button>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
</Stack>
)}
</Box>
</Collapse>
</Box>
</Box>
</Box>
</Collapse>
</TableCell>
</TableRow>
</>
);
</Collapse>
</TableCell>
</TableRow>
</>
);
}
}