diff --git a/src/api/backup.js b/src/api/backup.js index 8dfd74a..17145f7 100644 --- a/src/api/backup.js +++ b/src/api/backup.js @@ -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 }; + diff --git a/src/automats/backupAutomat.js b/src/automats/backupAutomat.js index 8d18f43..5677a05 100644 --- a/src/automats/backupAutomat.js +++ b/src/automats/backupAutomat.js @@ -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>} vfsFilesByBackup + * @property {Record} 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} The backup run result containing manifest and pruneResult + * @param {string} backupId Identifier of the backup + * @param {number} [companyId] Optional company ID override + * @returns {Promise>} */ - 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} Recovery result + */ + async recoverBackupVfs(backupId, path, companyId) { + return backupApi.recoverVfs(backupId, path, companyId); + }, + // #endregion + /** * Resets the automat state to initial empty values. */ diff --git a/src/components/settings/BackupSettingsPanel.jsx b/src/components/settings/BackupSettingsPanel.jsx index 3b9703b..15c615e 100644 --- a/src/components/settings/BackupSettingsPanel.jsx +++ b/src/components/settings/BackupSettingsPanel.jsx @@ -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 - Automatische Sicherung von Datenbank, virtuellen Dateisystemen (VFS) und Dokumentenspeicher. + Automatische Sicherung von Datenbank, virtuellem Dateisystem (VFS) und Dokumentenspeicher. @@ -228,20 +224,10 @@ export default class BackupSettingsPanel extends Component { size="small" startIcon={} onClick={this.handleRefresh} - disabled={isExecuting} + disabled={isRunning} > Aktualisieren - @@ -265,7 +251,7 @@ export default class BackupSettingsPanel extends Component { color={backupStatus?.enabled ? 'success' : 'default'} variant="outlined" /> - {isExecuting && ( + {isRunning && ( } @@ -410,7 +396,7 @@ export default class BackupSettingsPanel extends Component { ) : ( - + @@ -418,22 +404,24 @@ export default class BackupSettingsPanel extends Component { Zeitpunkt Archivname Größe - Typ + VFS-Dateien {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 ( this.handleToggleExpand(item.name)} /> ); @@ -448,120 +436,314 @@ export default class BackupSettingsPanel extends Component { } } -function BackupTableRow({ item, isExpanded, isManual, manifest, onToggleExpand }) { - return ( - <> - *': { borderBottom: isExpanded ? 'none !important' : undefined }, - }} - > - - { e.stopPropagation(); onToggleExpand(); }}> - {isExpanded ? : } - - - - {formatDate(item.timestamp)} - - - {item.name} - - - {formatBytes(item.totalSizeBytes)} - - - - - - - - - - - Backup-Details & Manifest - - - - - Datenbank - - - {manifest?.files?.database - ? `${formatBytes(manifest.files.database.sizeBytes)} (${manifest.files.database.backedUp ? 'OK' : manifest.files.database.reason || 'Übersprungen'})` - : '—'} - - - - - VFS (Dateien & Belege) - - - {manifest?.files?.vfs ? formatBytes(manifest.files.vfs.sizeBytes) : '—'} - - - - - Dokumentenspeicher (Storage) - - - {manifest?.files?.storage ? formatBytes(manifest.files.storage.sizeBytes) : '—'} - - - {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 ( + <> + *': { borderBottom: isExpanded ? 'none !important' : undefined }, + }} + > + + { e.stopPropagation(); onToggleExpand(); }}> + {isExpanded ? : } + + + + {formatDate(item.timestamp)} + + + {item.name} + + + {formatBytes(item.totalSizeBytes)} + + + } + label={manifest?.files?.vfs ? formatBytes(manifest.files.vfs.sizeBytes) : '—'} + variant="outlined" + sx={{ height: 22, fontSize: '0.75rem' }} + /> + + + + + + + + Backup-Details & Manifest + + - Dauer + Datenbank - {formatDuration(manifest.durationMs)} + {manifest?.files?.database + ? `${formatBytes(manifest.files.database.sizeBytes)} (${manifest.files.database.backedUp ? 'OK' : manifest.files.database.reason || 'Übersprungen'})` + : '—'} - )} - {manifest?.dbProvider && ( - Datenbank-Provider + VFS (Dateien & Belege) - {manifest.dbProvider.toUpperCase()} + {manifest?.files?.vfs ? formatBytes(manifest.files.vfs.sizeBytes) : '—'} - )} - - - Speicherpfad - - - {item.path} - + + + Dokumentenspeicher (Storage) + + + {manifest?.files?.storage ? formatBytes(manifest.files.storage.sizeBytes) : '—'} + + + {manifest?.durationMs != null && ( + + + Dauer + + + {formatDuration(manifest.durationMs)} + + + )} + {manifest?.dbProvider && ( + + + Datenbank-Provider + + + {manifest.dbProvider.toUpperCase()} + + + )} + + + Speicherpfad + + + {item.path} + + + + + {/* VFS File Explorer & Restore Section */} + + + + + + Archivierte VFS-Dateien & Wiederherstellung + + {Array.isArray(vfsFiles) && ( + + )} + + + + + + + {vfsLoading ? ( + + + + VFS-Archiv wird gelesen... + + + ) : !vfsFiles || vfsFiles.length === 0 ? ( + + Keine VFS-Dateien in dieser Sicherung für das aktuelle Unternehmen gefunden. + + ) : ( + + {vfsFiles.length > 3 && ( + this.setState({ vfsFilter: e.target.value })} + onClick={(e) => e.stopPropagation()} + slotProps={{ + input: { + startAdornment: ( + + + + ), + }, + }} + sx={{ maxWidth: 320 }} + /> + )} + + +
+ + + Dateipfad im VFS + Größe + Typ + Änderungsdatum + Aktion + + + + {filteredFiles.length === 0 ? ( + + + Keine Dateien entsprechen dem Suchbegriff. + + + ) : ( + filteredFiles.map((f) => { + const isRecovering = recoveringPath === f.name; + + return ( + + + + + + {f.name} + + + + + {formatBytes(f.size)} + + + {f.mime || '—'} + + + {formatDate(f.mtime)} + + + + + + ); + }) + )} + +
+
+ + )} + + - - - - - - ); + + + + + ); + } }