Files
dashfront/src/automats/backupAutomat.js
2026-10-14 03:15:43 +02:00

230 lines
7.3 KiB
JavaScript

/**
* @file backupAutomat.js
* @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()`, `actions.listBackupVfs()`, or `actions.recoverBackupVfs()`.
*/
import { Automat } from 'react-automat';
import { backup as backupApi } from '../api';
import authAutomat from './authAutomat';
import sseAutomat from './sseAutomat';
/**
* @typedef {Object} BackupItem
* @property {string} name Archive file or directory name
* @property {string} path Full storage path
* @property {number} totalSizeBytes Total size in bytes
* @property {string|null} timestamp Creation timestamp
* @property {object|null} [manifest] Backup manifest details
*/
/**
* @typedef {Object} BackupStatus
* @property {boolean} enabled Whether automated backups are enabled
* @property {boolean} isRunning Whether a backup is currently executing
* @property {string} dbProvider Database provider name (e.g. 'sqlite', 'mssql')
* @property {boolean} mssqlWarning Whether an MSSQL warning is present
* @property {string} backupDir Root directory where backups are stored
* @property {{ hourlyMax: number, dailyMax: number, monthly: string }} retention Retention configuration
* @property {{ hourly: number, daily: number, monthly: number }} counts Counts of existing backups
* @property {BackupItem|null} [latestHourly] Most recent hourly backup
* @property {BackupItem|null} [latestDaily] Most recent daily backup
* @property {BackupItem|null} [latestMonthly] Most recent monthly backup
*/
/**
* @typedef {Object} BackupState
* @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state
* @property {string|null} error Last error message
* @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 {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,
backupStatus: null,
backups: {
counts: { hourly: 0, daily: 0, monthly: 0 },
hourly: [],
daily: [],
monthly: [],
},
vfsFilesByBackup: {},
vfsLoadingByBackup: {},
};
let backupAutomat;
let inflightLoad = null;
// #region fetch
// MARK: calls GET /api/backup from ./api/index.js
async function fetchBackupData() {
const data = await backupApi.getStatus();
const status = data?.status ?? null;
const rawBackups = data?.backups ?? {};
const hourly = Array.isArray(rawBackups.hourly) ? rawBackups.hourly : [];
const daily = Array.isArray(rawBackups.daily) ? rawBackups.daily : [];
const monthly = Array.isArray(rawBackups.monthly) ? rawBackups.monthly : [];
const counts = rawBackups.counts ?? {
hourly: hourly.length,
daily: daily.length,
monthly: monthly.length,
};
return {
backupStatus: status,
backups: {
counts,
hourly,
daily,
monthly,
},
};
}
// #endregion
const actions = {
// #region load
// MARK: calls GET /api/backup from ./api/index.js
/**
* Loads backup status and archive listing.
* Deduplicates concurrent callers.
*
* @param {boolean} [silent=false] If true, keeps status as 'ready' without flashing 'loading'
* @returns {Promise<{ backupStatus: BackupStatus|null, backups: object }>}
*/
load(silent = false) {
if (inflightLoad) return inflightLoad;
if (!silent) {
backupAutomat.setState({ status: 'loading', error: null });
}
inflightLoad = (async () => {
try {
const { backupStatus, backups } = await fetchBackupData();
backupAutomat.setState({
backupStatus,
backups,
status: 'ready',
error: null,
});
return { backupStatus, backups };
} catch (err) {
const error = err?.message ?? String(err);
backupAutomat.setState(silent ? { error } : { status: 'failed', error });
throw err;
} finally {
inflightLoad = null;
}
})();
return inflightLoad;
},
// #endregion
// #region reload
// MARK: calls GET /api/backup from ./api/index.js
/**
* Silently re-fetches backup status and archives in the background.
*/
async reload() {
return actions.load(true);
},
// #endregion
// #region listBackupVfs
// MARK: calls GET /api/backup/:id/vfs from ./api/index.js
/**
* Retrieves the list of VFS files stored inside a specific backup.
*
* @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 listBackupVfs(backupId, companyId) {
if (!backupId) return [];
backupAutomat.setState({
vfsLoadingByBackup: { ...backupAutomat.state.vfsLoadingByBackup, [backupId]: true },
});
try {
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({
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.
*/
clear() {
backupAutomat.setState({ ...initialState });
},
};
backupAutomat = new Automat(initialState, actions, { name: 'backup' });
// #region sseListener
if (sseAutomat?.actions?.on) {
sseAutomat.actions.on('backup', () => {
void backupAutomat.actions.reload().catch(() => {});
});
}
// #endregion
// #region authSync
function authKey(authState) {
if (authState?.status !== 'authed' || !authState.user) return null;
const companyId = authState.user.selectedCompanyId;
return companyId == null ? null : String(companyId);
}
let lastAuthKey;
function syncWithAuth(authState) {
const key = authKey(authState);
if (key === lastAuthKey) return;
lastAuthKey = key;
if (key == null) {
backupAutomat.actions.clear();
} else {
void backupAutomat.actions.load().catch(() => {});
}
}
authAutomat.subscribe(syncWithAuth);
syncWithAuth(authAutomat.state);
// #endregion
export default backupAutomat;