u
This commit is contained in:
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user