u
This commit is contained in:
209
src/automats/backupAutomat.js
Normal file
209
src/automats/backupAutomat.js
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* @file backupAutomat.js
|
||||
* @description Manages backup status and backup archives (`GET /backup`, `POST /backup/run`).
|
||||
*
|
||||
* 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()`.
|
||||
*/
|
||||
|
||||
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 {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
|
||||
*/
|
||||
|
||||
/** @type {BackupState} */
|
||||
const initialState = {
|
||||
status: 'idle',
|
||||
error: null,
|
||||
runningBackup: false,
|
||||
backupStatus: null,
|
||||
backups: {
|
||||
counts: { hourly: 0, daily: 0, monthly: 0 },
|
||||
hourly: [],
|
||||
daily: [],
|
||||
monthly: [],
|
||||
},
|
||||
lastRunResult: null,
|
||||
};
|
||||
|
||||
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 runBackup
|
||||
// MARK: calls POST /api/backup/run from ./api/index.js
|
||||
/**
|
||||
* Triggers an immediate manual backup run on the backend.
|
||||
*
|
||||
* @returns {Promise<object>} The backup run result containing manifest and pruneResult
|
||||
*/
|
||||
async runBackup() {
|
||||
backupAutomat.setState({ runningBackup: true, error: null });
|
||||
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;
|
||||
} finally {
|
||||
backupAutomat.setState({ runningBackup: false });
|
||||
}
|
||||
},
|
||||
// #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;
|
||||
Reference in New Issue
Block a user