230 lines
7.3 KiB
JavaScript
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;
|