Move database backup policy and scheduling to shared global configuration. - Split project memory backups from cluster-wide database backup settings. - Migrate legacy backup values and routines safely into central global storage. - Schedule and dispatch one shared PostgreSQL backup routine across project engines. Files changed: .changeset/fn-8129-backup-settings-scope-split.md | 7 + docs/dashboard-guide.md | 2 + docs/settings-reference.md | 10 +- packages/cli/src/commands/backup.ts | 3 +- .../__tests__/backup-settings-migration.test.ts | 50 ++++++ .../src/__tests__/backup-settings-scope.test.ts | 27 +++ packages/core/src/backup-settings-migration.ts | 188 +++++++++++++++++++++ packages/core/src/backup.ts | 77 +++++---- packages/core/src/global-routine-store.ts | 104 ++++++++++++ packages/core/src/index.gate.ts | 6 +- packages/core/src/index.ts | 6 +- .../core/src/postgres/migrations/0000_initial.sql | 19 +++ .../postgres/migrations/0015_global_routines.sql | 19 +++ packages/core/src/postgres/schema-applier.ts | 19 ++- packages/core/src/postgres/schema/central.ts | 21 ++- packages/core/src/postgres/startup-factory.ts | 11 ++ packages/core/src/settings-schema.ts | 14 +- packages/core/src/types.ts | 31 +++- .../dashboard/app/components/SettingsModal.tsx | 10 +- .../settings/__tests__/section-keys.test.ts | 1 + .../app/components/settings/save-split.ts | 2 + .../search/__tests__/settings-search-index.test.ts | 1 + .../settings/search/entries.ts | 2 + .../app/components/settings/section-keys.ts | 4 - .../settings/sections/BackupsSection.search.ts | 40 ----- .../settings/sections/BackupsSection.tsx | 112 +----------- .../sections/DatabaseBackupsSection.search.ts | 51 ++++++ .../settings/sections/DatabaseBackupsSection.tsx | 142 ++++++++++++++++ .../settings-default-descriptions.test.tsx | 1 + packages/dashboard/src/routes.ts | 12 +- .../src/routes/register-settings-memory-routes.ts | 41 ++--- .../engine/src/__tests__/routine-scheduler.test.ts | 55 +++++- packages/engine/src/cron-runner.ts | 4 +- packages/engine/src/routine-runner.ts | 67 +++++--- packages/engine/src/routine-scheduler.ts | 35 +++- 35 files changed, 929 insertions(+), 265 deletions(-) Fusion-Task-Id: FN-8129 Fusion-Task-Lineage: af17f39a-7f1c-40ff-8a4a-cd63895cd532 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
143 lines
7.8 KiB
TypeScript
143 lines
7.8 KiB
TypeScript
import { useTranslation } from "react-i18next";
|
|
import type { BackupListResponse } from "../../../api";
|
|
import { SettingsToggleRow } from "../SettingsToggleRow";
|
|
import { SettingsNumberRow } from "../SettingsNumberRow";
|
|
import { SettingsTextRow } from "../SettingsTextRow";
|
|
import type { BackupSettingsMigrationCandidate, BackupSettingsMigrationConflict } from "@fusion/core";
|
|
import type { SectionBaseProps } from "./context";
|
|
import { LoadingSpinner } from "../../LoadingSpinner";
|
|
export interface DatabaseBackupsSectionProps extends SectionBaseProps {
|
|
backupInfo: BackupListResponse | null;
|
|
backupLoading: boolean;
|
|
onBackupNow: () => void;
|
|
}
|
|
/*
|
|
FNXC:SettingsBackups 2026-07-16-14:35:
|
|
The global PostgreSQL cluster contains every project, so database controls and the one shared backup destination live in this global section. Memory snapshots remain in the project section.
|
|
*/
|
|
export function DatabaseBackupsSection({ form, setForm, backupInfo, backupLoading, onBackupNow }: DatabaseBackupsSectionProps) {
|
|
const { t } = useTranslation("app");
|
|
const conflicts = form.backupSettingsMigrationConflicts ?? [];
|
|
const chooseCandidate = (conflict: BackupSettingsMigrationConflict, candidate: BackupSettingsMigrationCandidate) => {
|
|
setForm((current) => ({
|
|
...current,
|
|
[conflict.key]: candidate.value,
|
|
backupSettingsMigrationConflicts: conflicts.filter((entry) => entry.key !== conflict.key),
|
|
}));
|
|
};
|
|
return (<>
|
|
{conflicts.length > 0 && <div className="settings-notice settings-notice-warning" role="status">
|
|
<strong>{t("settings.backups.migrationConflict", "Choose database backup settings")}</strong>
|
|
<p>{t("settings.backups.migrationConflictHelp", "Existing projects used different backup settings. Choose the value to use globally.")}</p>
|
|
{conflicts.map((conflict) => <div key={conflict.key}>
|
|
<span>{conflict.key}</span>
|
|
{conflict.candidates.map((candidate, index) => <button
|
|
type="button"
|
|
className="btn btn-sm"
|
|
key={`${candidate.source}-${candidate.projectId ?? "global"}-${index}`}
|
|
onClick={() => chooseCandidate(conflict, candidate)}
|
|
>
|
|
{candidate.source === "global"
|
|
? t("settings.backups.existingGlobalValue", "Existing global value")
|
|
: `${t("settings.backups.projectValue", "Project value")}: ${candidate.projectId}`}
|
|
</button>)}
|
|
</div>)}
|
|
</div>}
|
|
<h4 className="settings-section-heading">{t("settings.backups.databaseBackups", "Database Backups")}</h4>
|
|
<SettingsToggleRow
|
|
descriptor={{
|
|
key: "autoBackupEnabled",
|
|
label: t("settings.backups.enableAutomaticDatabaseBackups", " Enable automatic database backups "),
|
|
help: t("settings.backups.whenEnabledTheDatabaseIsBackedUpAutomatically", "When enabled, the database is backed up automatically on a schedule. Default: disabled."),
|
|
scope: "global",
|
|
}}
|
|
value={form.autoBackupEnabled || false}
|
|
onChange={(v) => setForm((f) => ({ ...f, autoBackupEnabled: v === true }))}
|
|
/>
|
|
<SettingsTextRow
|
|
descriptor={{
|
|
key: "autoBackupSchedule",
|
|
label: t("settings.backups.backupScheduleCron", "Backup Schedule (Cron)"),
|
|
help: t("settings.backups.cronExpressionForBackupTimingDefault02", " Cron expression for backup timing. Default: 0 2 * * * (daily at 2 AM). Examples: 0 * * * * (hourly), 0 0 * * 0 (weekly), */15 * * * * (every 15 min) "),
|
|
scope: "global",
|
|
placeholder: t("settings.backups.02", "0 2 * * *"),
|
|
disabled: !form.autoBackupEnabled,
|
|
}}
|
|
value={form.autoBackupSchedule || "0 2 * * *"}
|
|
onChange={(v) => setForm((f) => ({ ...f, autoBackupSchedule: v ?? "" }))}
|
|
error={form.autoBackupSchedule && !/^[\s\d*,/-]+$/.test(form.autoBackupSchedule)
|
|
? t("settings.backups.invalidCronExpressionFormat", "Invalid cron expression format")
|
|
: undefined}
|
|
/>
|
|
<SettingsNumberRow
|
|
descriptor={{
|
|
key: "autoBackupRetention",
|
|
label: t("settings.backups.retentionCount", "Retention Count"),
|
|
help: t("settings.backups.numberOfBackupFilesToKeepOldestAre", "Number of backup files to keep (oldest are deleted first). Range: 1-100. Default: 7."),
|
|
scope: "global",
|
|
min: 1,
|
|
max: 100,
|
|
disabled: !form.autoBackupEnabled,
|
|
}}
|
|
value={form.autoBackupRetention ?? null}
|
|
onChange={(v) => setForm((f) => ({ ...f, autoBackupRetention: v ?? undefined }))}
|
|
error={form.autoBackupRetention !== undefined && (form.autoBackupRetention < 1 || form.autoBackupRetention > 100)
|
|
? t("settings.backups.mustBeBetween1And100", "Must be between 1 and 100")
|
|
: undefined}
|
|
/>
|
|
<SettingsTextRow
|
|
descriptor={{
|
|
key: "autoBackupDir",
|
|
label: t("settings.backups.backupDirectory", "Backup Directory"),
|
|
help: t("settings.backups.directoryForBackupFilesRelativeToProjectRoot", "Directory for backup files, relative to the global Fusion directory. Default: .fusion/backups."),
|
|
scope: "global",
|
|
placeholder: t("settings.backups.fusionBackups", ".fusion/backups"),
|
|
disabled: !form.autoBackupEnabled,
|
|
}}
|
|
value={form.autoBackupDir || ".fusion/backups"}
|
|
onChange={(v) => setForm((f) => ({ ...f, autoBackupDir: v ?? "" }))}
|
|
error={form.autoBackupDir && form.autoBackupDir.includes("..")
|
|
? t("settings.backups.pathCannotContainParentDirectoryTraversal", "Path cannot contain parent directory traversal (..)")
|
|
: undefined}
|
|
/>
|
|
|
|
{backupLoading ? (<div className="settings-empty-state"><LoadingSpinner label={t("settings.backups.loadingBackupInfo", "Loading backup info\u2026")} /></div>) : backupInfo ? (<div className="form-group">
|
|
<label>{t("settings.backups.currentBackups", "Current Backups")}</label>
|
|
<div className="backup-stats">
|
|
<div className="backup-stat">
|
|
<span className="backup-stat-value">{backupInfo.count}</span>
|
|
<span className="backup-stat-label">{t("settings.backups.backups", "backups")}</span>
|
|
</div>
|
|
<div className="backup-stat">
|
|
<span className="backup-stat-value">
|
|
{backupInfo.totalSize > 1024 * 1024
|
|
? `${(backupInfo.totalSize / (1024 * 1024)).toFixed(1)} MB`
|
|
: `${(backupInfo.totalSize / 1024).toFixed(1)} KB`}
|
|
</span>
|
|
<span className="backup-stat-label">{t("settings.backups.totalSize", "total size")}</span>
|
|
</div>
|
|
</div>
|
|
{backupInfo.backups.length > 0 && (<details className="backup-list">
|
|
<summary>{t("settings.backups.view", "View ")}{backupInfo.backups.length}{t("settings.backups.backupS", " backup(s)")}</summary>
|
|
<ul>
|
|
{backupInfo.backups.slice(0, 10).map((backup) => (<li key={backup.filename}>
|
|
<code>{backup.filename}</code>
|
|
<span className="backup-size">
|
|
{backup.size > 1024 * 1024
|
|
? `${(backup.size / (1024 * 1024)).toFixed(1)} MB`
|
|
: `${(backup.size / 1024).toFixed(1)} KB`}
|
|
</span>
|
|
</li>))}
|
|
{backupInfo.backups.length > 10 && (<li><em>{t("settings.backups.and", "...and ")}{backupInfo.backups.length - 10}{t("settings.backups.more", " more")}</em></li>)}
|
|
</ul>
|
|
</details>)}
|
|
</div>) : null}
|
|
<div className="form-group">
|
|
<button type="button" className="btn btn-sm" onClick={onBackupNow} disabled={backupLoading}>
|
|
{backupLoading ? t("settings.backups.creating", "Creating…") : t("settings.backups.backupNow", "Backup Now")}
|
|
</button>
|
|
</div>
|
|
</>);
|
|
}
|
|
export default DatabaseBackupsSection;
|