u
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { http } from './http.js';
|
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 = {
|
export const backup = {
|
||||||
/**
|
/**
|
||||||
@@ -16,16 +16,36 @@ export const backup = {
|
|||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/backup/run
|
* GET /api/backup/:id/vfs
|
||||||
* Triggers a manual backup run.
|
* Lists VFS files archived in a specific backup for a company.
|
||||||
* Returns: 200 { success: bool, id: str, timestamp: str, promotedDaily: bool, promotedMonthly: bool, totalSizeBytes: int, pruneResult: { hourlyPruned: int, dailyPruned: int }, manifest: object }
|
* @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
|
// #region GET /api/backup/{id}/vfs — listVfs
|
||||||
// MARK: calls POST /api/backup/run from ./api/index.js
|
// MARK: calls GET /api/backup/:id/vfs from ./api/index.js
|
||||||
run() {
|
listVfs(id, companyId) {
|
||||||
return http('/api/backup/run', {
|
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',
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ path, companyId }),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
// #endregion
|
// #endregion
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* @file backupAutomat.js
|
* @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:
|
* Upstream Triggers:
|
||||||
* - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on
|
* - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on
|
||||||
* `'authed'` with a `selectedCompanyId`, and `actions.clear()` on `'anon'`.
|
* `'authed'` with a `selectedCompanyId`, and `actions.clear()` on `'anon'`.
|
||||||
* - `sseAutomat`: Subscribes to `'backup'` event (if dispatched) to reload silently.
|
* - `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';
|
import { Automat } from 'react-automat';
|
||||||
@@ -41,17 +41,16 @@ import sseAutomat from './sseAutomat';
|
|||||||
* @typedef {Object} BackupState
|
* @typedef {Object} BackupState
|
||||||
* @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state
|
* @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state
|
||||||
* @property {string|null} error Last error message
|
* @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 {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 {{ 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} */
|
/** @type {BackupState} */
|
||||||
const initialState = {
|
const initialState = {
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
error: null,
|
error: null,
|
||||||
runningBackup: false,
|
|
||||||
backupStatus: null,
|
backupStatus: null,
|
||||||
backups: {
|
backups: {
|
||||||
counts: { hourly: 0, daily: 0, monthly: 0 },
|
counts: { hourly: 0, daily: 0, monthly: 0 },
|
||||||
@@ -59,7 +58,8 @@ const initialState = {
|
|||||||
daily: [],
|
daily: [],
|
||||||
monthly: [],
|
monthly: [],
|
||||||
},
|
},
|
||||||
lastRunResult: null,
|
vfsFilesByBackup: {},
|
||||||
|
vfsLoadingByBackup: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
let backupAutomat;
|
let backupAutomat;
|
||||||
@@ -141,30 +141,50 @@ const actions = {
|
|||||||
},
|
},
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
// #region runBackup
|
// #region listBackupVfs
|
||||||
// MARK: calls POST /api/backup/run from ./api/index.js
|
// 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() {
|
async listBackupVfs(backupId, companyId) {
|
||||||
backupAutomat.setState({ runningBackup: true, error: null });
|
if (!backupId) return [];
|
||||||
|
backupAutomat.setState({
|
||||||
|
vfsLoadingByBackup: { ...backupAutomat.state.vfsLoadingByBackup, [backupId]: true },
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const result = await backupApi.run();
|
const res = await backupApi.listVfs(backupId, companyId);
|
||||||
backupAutomat.setState({ lastRunResult: result });
|
const files = Array.isArray(res?.files) ? res.files : [];
|
||||||
await actions.reload();
|
backupAutomat.setState({
|
||||||
return result;
|
vfsFilesByBackup: { ...backupAutomat.state.vfsFilesByBackup, [backupId]: files },
|
||||||
} catch (err) {
|
});
|
||||||
const error = err?.message ?? String(err);
|
return files;
|
||||||
backupAutomat.setState({ error });
|
|
||||||
throw err;
|
|
||||||
} finally {
|
} finally {
|
||||||
backupAutomat.setState({ runningBackup: false });
|
backupAutomat.setState({
|
||||||
|
vfsLoadingByBackup: { ...backupAutomat.state.vfsLoadingByBackup, [backupId]: false },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// #endregion
|
// #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.
|
* 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 Collapse from '@mui/material/Collapse';
|
||||||
import Divider from '@mui/material/Divider';
|
import Divider from '@mui/material/Divider';
|
||||||
import IconButton from '@mui/material/IconButton';
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
import Stack from '@mui/material/Stack';
|
import Stack from '@mui/material/Stack';
|
||||||
import Tab from '@mui/material/Tab';
|
import Tab from '@mui/material/Tab';
|
||||||
import Table from '@mui/material/Table';
|
import Table from '@mui/material/Table';
|
||||||
@@ -18,15 +19,18 @@ import TableContainer from '@mui/material/TableContainer';
|
|||||||
import TableHead from '@mui/material/TableHead';
|
import TableHead from '@mui/material/TableHead';
|
||||||
import TableRow from '@mui/material/TableRow';
|
import TableRow from '@mui/material/TableRow';
|
||||||
import Tabs from '@mui/material/Tabs';
|
import Tabs from '@mui/material/Tabs';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import BackupOutlinedIcon from '@mui/icons-material/BackupOutlined';
|
import BackupOutlinedIcon from '@mui/icons-material/BackupOutlined';
|
||||||
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutlined';
|
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutlined';
|
||||||
import CloudQueueOutlinedIcon from '@mui/icons-material/CloudQueueOutlined';
|
import CloudQueueOutlinedIcon from '@mui/icons-material/CloudQueueOutlined';
|
||||||
import FolderOutlinedIcon from '@mui/icons-material/FolderOutlined';
|
import FolderOutlinedIcon from '@mui/icons-material/FolderOutlined';
|
||||||
|
import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined';
|
||||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||||
import PlayArrowOutlinedIcon from '@mui/icons-material/PlayArrowOutlined';
|
|
||||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
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 StorageIcon from '@mui/icons-material/Storage';
|
||||||
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
|
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
@@ -34,7 +38,6 @@ import backupAutomat from '../../automats/backupAutomat';
|
|||||||
import { notifyError } from '../../utils/notify';
|
import { notifyError } from '../../utils/notify';
|
||||||
import {
|
import {
|
||||||
BORDER,
|
BORDER,
|
||||||
CHROMA,
|
|
||||||
DARK_1,
|
DARK_1,
|
||||||
DARK_2,
|
DARK_2,
|
||||||
DARK_4,
|
DARK_4,
|
||||||
@@ -79,19 +82,19 @@ function formatDuration(ms) {
|
|||||||
export default class BackupSettingsPanel extends Component {
|
export default class BackupSettingsPanel extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
const { status, error, runningBackup, backupStatus, backups } = backupAutomat.state;
|
const { status, error, backupStatus, backups, vfsFilesByBackup, vfsLoadingByBackup } = backupAutomat.state;
|
||||||
this.state = {
|
this.state = {
|
||||||
status,
|
status,
|
||||||
error,
|
error,
|
||||||
runningBackup,
|
|
||||||
backupStatus,
|
backupStatus,
|
||||||
backups,
|
backups,
|
||||||
|
vfsFilesByBackup: vfsFilesByBackup || {},
|
||||||
|
vfsLoadingByBackup: vfsLoadingByBackup || {},
|
||||||
tierTab: 'hourly', // 'hourly' | 'daily' | 'monthly'
|
tierTab: 'hourly', // 'hourly' | 'daily' | 'monthly'
|
||||||
expandedItemName: null,
|
expandedItemName: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.handleRefresh = this.handleRefresh.bind(this);
|
this.handleRefresh = this.handleRefresh.bind(this);
|
||||||
this.handleRunBackup = this.handleRunBackup.bind(this);
|
|
||||||
this.handleTierChange = this.handleTierChange.bind(this);
|
this.handleTierChange = this.handleTierChange.bind(this);
|
||||||
this.handleToggleExpand = this.handleToggleExpand.bind(this);
|
this.handleToggleExpand = this.handleToggleExpand.bind(this);
|
||||||
}
|
}
|
||||||
@@ -100,9 +103,10 @@ export default class BackupSettingsPanel extends Component {
|
|||||||
this.unsub = backupAutomat.subscribe(this, (state) => ({
|
this.unsub = backupAutomat.subscribe(this, (state) => ({
|
||||||
status: state.status,
|
status: state.status,
|
||||||
error: state.error,
|
error: state.error,
|
||||||
runningBackup: state.runningBackup,
|
|
||||||
backupStatus: state.backupStatus,
|
backupStatus: state.backupStatus,
|
||||||
backups: state.backups,
|
backups: state.backups,
|
||||||
|
vfsFilesByBackup: state.vfsFilesByBackup,
|
||||||
|
vfsLoadingByBackup: state.vfsLoadingByBackup,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (backupAutomat.state.status === 'idle') {
|
if (backupAutomat.state.status === 'idle') {
|
||||||
@@ -137,23 +141,6 @@ export default class BackupSettingsPanel extends Component {
|
|||||||
}
|
}
|
||||||
// #endregion
|
// #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) {
|
handleTierChange(_event, newValue) {
|
||||||
this.setState({ tierTab: newValue, expandedItemName: null });
|
this.setState({ tierTab: newValue, expandedItemName: null });
|
||||||
}
|
}
|
||||||
@@ -165,7 +152,16 @@ export default class BackupSettingsPanel extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
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) {
|
if (status === 'loading' && !backupStatus) {
|
||||||
return (
|
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 retention = backupStatus?.retention ?? { hourlyMax: 24, dailyMax: 7, monthly: 'Monatserster' };
|
||||||
const counts = backups?.counts ?? { hourly: 0, daily: 0, monthly: 0 };
|
const counts = backups?.counts ?? { hourly: 0, daily: 0, monthly: 0 };
|
||||||
const hourlyList = backups?.hourly || [];
|
const hourlyList = backups?.hourly || [];
|
||||||
@@ -219,7 +215,7 @@ export default class BackupSettingsPanel extends Component {
|
|||||||
Datensicherung & Backups
|
Datensicherung & Backups
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
<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>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}>
|
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}>
|
||||||
@@ -228,20 +224,10 @@ export default class BackupSettingsPanel extends Component {
|
|||||||
size="small"
|
size="small"
|
||||||
startIcon={<RefreshIcon />}
|
startIcon={<RefreshIcon />}
|
||||||
onClick={this.handleRefresh}
|
onClick={this.handleRefresh}
|
||||||
disabled={isExecuting}
|
disabled={isRunning}
|
||||||
>
|
>
|
||||||
Aktualisieren
|
Aktualisieren
|
||||||
</Button>
|
</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>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -265,7 +251,7 @@ export default class BackupSettingsPanel extends Component {
|
|||||||
color={backupStatus?.enabled ? 'success' : 'default'}
|
color={backupStatus?.enabled ? 'success' : 'default'}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
/>
|
/>
|
||||||
{isExecuting && (
|
{isRunning && (
|
||||||
<Chip
|
<Chip
|
||||||
size="small"
|
size="small"
|
||||||
icon={<CircularProgress size={12} color="inherit" />}
|
icon={<CircularProgress size={12} color="inherit" />}
|
||||||
@@ -410,7 +396,7 @@ export default class BackupSettingsPanel extends Component {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<TableContainer sx={{ maxHeight: 360 }}>
|
<TableContainer sx={{ maxHeight: 480 }}>
|
||||||
<Table size="small" stickyHeader>
|
<Table size="small" stickyHeader>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -418,22 +404,24 @@ export default class BackupSettingsPanel extends Component {
|
|||||||
<TableCell sx={{ fontWeight: 600 }}>Zeitpunkt</TableCell>
|
<TableCell sx={{ fontWeight: 600 }}>Zeitpunkt</TableCell>
|
||||||
<TableCell sx={{ fontWeight: 600 }}>Archivname</TableCell>
|
<TableCell sx={{ fontWeight: 600 }}>Archivname</TableCell>
|
||||||
<TableCell sx={{ fontWeight: 600 }}>Größe</TableCell>
|
<TableCell sx={{ fontWeight: 600 }}>Größe</TableCell>
|
||||||
<TableCell sx={{ fontWeight: 600 }}>Typ</TableCell>
|
<TableCell sx={{ fontWeight: 600 }}>VFS-Dateien</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{activeList.map((item) => {
|
{activeList.map((item) => {
|
||||||
const isExpanded = expandedItemName === item.name;
|
const isExpanded = expandedItemName === item.name;
|
||||||
const manifest = item.manifest;
|
const manifest = item.manifest;
|
||||||
const isManual = Boolean(manifest?.manual);
|
const vfsFiles = vfsFilesByBackup[item.name];
|
||||||
|
const vfsLoading = Boolean(vfsLoadingByBackup[item.name]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BackupTableRow
|
<BackupTableRow
|
||||||
key={item.name}
|
key={item.name}
|
||||||
item={item}
|
item={item}
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
isManual={isManual}
|
|
||||||
manifest={manifest}
|
manifest={manifest}
|
||||||
|
vfsFiles={vfsFiles}
|
||||||
|
vfsLoading={vfsLoading}
|
||||||
onToggleExpand={() => this.handleToggleExpand(item.name)}
|
onToggleExpand={() => this.handleToggleExpand(item.name)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -448,120 +436,314 @@ export default class BackupSettingsPanel extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function BackupTableRow({ item, isExpanded, isManual, manifest, onToggleExpand }) {
|
class BackupTableRow extends Component {
|
||||||
return (
|
constructor(props) {
|
||||||
<>
|
super(props);
|
||||||
<TableRow
|
this.state = {
|
||||||
hover
|
vfsFilter: '',
|
||||||
onClick={onToggleExpand}
|
recoveringPath: null,
|
||||||
sx={{
|
vfsExplorerOpen: false,
|
||||||
cursor: 'pointer',
|
};
|
||||||
bgcolor: isExpanded ? LIGHT_3 : 'inherit',
|
|
||||||
'& > *': { borderBottom: isExpanded ? 'none !important' : undefined },
|
this.handleToggleVfsExplorer = this.handleToggleVfsExplorer.bind(this);
|
||||||
}}
|
this.handleRecoverFile = this.handleRecoverFile.bind(this);
|
||||||
>
|
}
|
||||||
<TableCell sx={{ py: 1 }}>
|
|
||||||
<IconButton size="small" onClick={(e) => { e.stopPropagation(); onToggleExpand(); }}>
|
// #region handleToggleVfsExplorer
|
||||||
{isExpanded ? <KeyboardArrowUpIcon fontSize="small" /> : <KeyboardArrowDownIcon fontSize="small" />}
|
// MARK: calls backupAutomat.actions.listBackupVfs from ./api/index.js
|
||||||
</IconButton>
|
async handleToggleVfsExplorer(e) {
|
||||||
</TableCell>
|
e?.stopPropagation?.();
|
||||||
<TableCell sx={{ py: 1, fontWeight: 500 }}>
|
const nextOpen = !this.state.vfsExplorerOpen;
|
||||||
{formatDate(item.timestamp)}
|
this.setState({ vfsExplorerOpen: nextOpen });
|
||||||
</TableCell>
|
|
||||||
<TableCell sx={{ py: 1, color: DARK_2, fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
if (nextOpen && !this.props.vfsFiles && !this.props.vfsLoading) {
|
||||||
{item.name}
|
try {
|
||||||
</TableCell>
|
await backupAutomat.actions.listBackupVfs(this.props.item.name);
|
||||||
<TableCell sx={{ py: 1, fontWeight: 600 }}>
|
} catch (err) {
|
||||||
{formatBytes(item.totalSizeBytes)}
|
notifyError(err);
|
||||||
</TableCell>
|
}
|
||||||
<TableCell sx={{ py: 1 }}>
|
}
|
||||||
<Chip
|
}
|
||||||
size="small"
|
// #endregion
|
||||||
label={isManual ? 'Manuell' : 'Automatisch'}
|
|
||||||
color={isManual ? 'primary' : 'default'}
|
// #region handleRecoverFile
|
||||||
variant="outlined"
|
// MARK: calls POST /backup/:id/vfs/recover via backupAutomat.actions.recoverBackupVfs from ./api/index.js
|
||||||
sx={{ height: 22, fontSize: '0.75rem' }}
|
async handleRecoverFile(e, path) {
|
||||||
/>
|
e?.stopPropagation?.();
|
||||||
</TableCell>
|
if (this.state.recoveringPath) return;
|
||||||
</TableRow>
|
|
||||||
<TableRow sx={{ bgcolor: LIGHT_3 }}>
|
this.setState({ recoveringPath: path });
|
||||||
<TableCell colSpan={5} sx={{ py: 0, px: 3 }}>
|
try {
|
||||||
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
|
await backupAutomat.actions.recoverBackupVfs(this.props.item.name, path);
|
||||||
<Box sx={{ py: 2 }}>
|
enqueueSnackbar(`Datei "${path}" erfolgreich ins VFS wiederhergestellt.`, {
|
||||||
<Typography variant="caption" fontWeight={700} color="text.secondary" sx={{ textTransform: 'uppercase' }}>
|
variant: 'success',
|
||||||
Backup-Details & Manifest
|
});
|
||||||
</Typography>
|
} catch (err) {
|
||||||
<Box
|
notifyError(err);
|
||||||
sx={{
|
} finally {
|
||||||
display: 'grid',
|
this.setState({ recoveringPath: null });
|
||||||
gridTemplateColumns: { xs: '1fr', sm: 'repeat(3, 1fr)' },
|
}
|
||||||
gap: 1.5,
|
}
|
||||||
mt: 1,
|
// #endregion
|
||||||
p: 1.5,
|
|
||||||
borderRadius: 1.5,
|
render() {
|
||||||
bgcolor: WHITE,
|
const { item, isExpanded, manifest, vfsFiles, vfsLoading, onToggleExpand } = this.props;
|
||||||
border: `1px solid ${BORDER}`,
|
const { vfsFilter, recoveringPath, vfsExplorerOpen } = this.state;
|
||||||
}}
|
|
||||||
>
|
const filteredFiles = Array.isArray(vfsFiles)
|
||||||
<Box>
|
? vfsFilter.trim()
|
||||||
<Typography variant="caption" color="text.secondary" display="block">
|
? vfsFiles.filter((f) => f.name.toLowerCase().includes(vfsFilter.toLowerCase()))
|
||||||
Datenbank
|
: vfsFiles
|
||||||
</Typography>
|
: [];
|
||||||
<Typography variant="body2" fontWeight={600}>
|
|
||||||
{manifest?.files?.database
|
return (
|
||||||
? `${formatBytes(manifest.files.database.sizeBytes)} (${manifest.files.database.backedUp ? 'OK' : manifest.files.database.reason || 'Übersprungen'})`
|
<>
|
||||||
: '—'}
|
<TableRow
|
||||||
</Typography>
|
hover
|
||||||
</Box>
|
onClick={onToggleExpand}
|
||||||
<Box>
|
sx={{
|
||||||
<Typography variant="caption" color="text.secondary" display="block">
|
cursor: 'pointer',
|
||||||
VFS (Dateien & Belege)
|
bgcolor: isExpanded ? LIGHT_3 : 'inherit',
|
||||||
</Typography>
|
'& > *': { borderBottom: isExpanded ? 'none !important' : undefined },
|
||||||
<Typography variant="body2" fontWeight={600}>
|
}}
|
||||||
{manifest?.files?.vfs ? formatBytes(manifest.files.vfs.sizeBytes) : '—'}
|
>
|
||||||
</Typography>
|
<TableCell sx={{ py: 1 }}>
|
||||||
</Box>
|
<IconButton size="small" onClick={(e) => { e.stopPropagation(); onToggleExpand(); }}>
|
||||||
<Box>
|
{isExpanded ? <KeyboardArrowUpIcon fontSize="small" /> : <KeyboardArrowDownIcon fontSize="small" />}
|
||||||
<Typography variant="caption" color="text.secondary" display="block">
|
</IconButton>
|
||||||
Dokumentenspeicher (Storage)
|
</TableCell>
|
||||||
</Typography>
|
<TableCell sx={{ py: 1, fontWeight: 500 }}>
|
||||||
<Typography variant="body2" fontWeight={600}>
|
{formatDate(item.timestamp)}
|
||||||
{manifest?.files?.storage ? formatBytes(manifest.files.storage.sizeBytes) : '—'}
|
</TableCell>
|
||||||
</Typography>
|
<TableCell sx={{ py: 1, color: DARK_2, fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
||||||
</Box>
|
{item.name}
|
||||||
{manifest?.durationMs != null && (
|
</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>
|
<Box>
|
||||||
<Typography variant="caption" color="text.secondary" display="block">
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
Dauer
|
Datenbank
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" fontWeight={600}>
|
<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>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
|
||||||
{manifest?.dbProvider && (
|
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="caption" color="text.secondary" display="block">
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
Datenbank-Provider
|
VFS (Dateien & Belege)
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" fontWeight={600}>
|
<Typography variant="body2" fontWeight={600}>
|
||||||
{manifest.dbProvider.toUpperCase()}
|
{manifest?.files?.vfs ? formatBytes(manifest.files.vfs.sizeBytes) : '—'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
<Box>
|
||||||
<Box sx={{ gridColumn: { sm: 'span 3' } }}>
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
<Typography variant="caption" color="text.secondary" display="block">
|
Dokumentenspeicher (Storage)
|
||||||
Speicherpfad
|
</Typography>
|
||||||
</Typography>
|
<Typography variant="body2" fontWeight={600}>
|
||||||
<Typography variant="caption" sx={{ fontFamily: 'monospace', color: DARK_2, wordBreak: 'break-all' }}>
|
{manifest?.files?.storage ? formatBytes(manifest.files.storage.sizeBytes) : '—'}
|
||||||
{item.path}
|
</Typography>
|
||||||
</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>
|
</Box>
|
||||||
</Box>
|
</Collapse>
|
||||||
</Collapse>
|
</TableCell>
|
||||||
</TableCell>
|
</TableRow>
|
||||||
</TableRow>
|
</>
|
||||||
</>
|
);
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user