Files
fusion/packages/dashboard/app/components/settings/sections/DatabaseBackupsSection.tsx
gsxdsm 873f339ddd FN-9040: show backup status and preserve schedules
Expose database backup inventory and schedule execution evidence while keeping scheduled backups due after unchanged settings saves.

- Display backup files, empty and error states, and automatic schedule details in Settings.
- Reconcile the shared backup routine at engine startup and retain its next run when settings are unchanged.
- Add API, localization, documentation, and regression coverage for backup status and scheduling.

Files changed:
 .changeset/fn-9040-database-backups-visibility.md  |   7 ++
 docs/dashboard-guide.md                            |   2 +-
 docs/settings-reference.md                         |   4 +
 .../src/__tests__/backup-schedule-status.test.ts   | 124 +++++++++++++++++++++
 .../core/src/automation/global-routine-store.ts    |  31 +++++-
 packages/core/src/backup/backup.ts                 |  95 ++++++++++++++--
 packages/core/src/index.gate.ts                    |   4 +-
 packages/core/src/index.ts                         |   4 +-
 .../app/api/agents/agent-import-generation.ts      |  14 +++
 packages/dashboard/app/api/legacy.ts               |   1 +
 .../dashboard/app/components/SettingsModal.tsx     |  12 +-
 .../__tests__/SettingsModal.test-harness.tsx       |   7 +-
 .../__tests__/SettingsModalNodeRouting.test.tsx    |   2 +-
 .../components/__tests__/settings-mobile.test.tsx  |   2 +-
 .../settings/sections/DatabaseBackupsSection.css   |  24 ++++
 .../settings/sections/DatabaseBackupsSection.tsx   |  67 ++++++-----
 .../__tests__/DatabaseBackupsSection.test.tsx      |  45 ++++++++
 .../register-system-maintenance-routes.test.ts     |  47 +++++++-
 .../routes/register-system-maintenance-routes.ts   |  46 ++++++--
 .../src/__tests__/in-process-runtime.pg.test.ts    |  15 ++-
 packages/engine/src/runtimes/in-process-runtime.ts |  13 +++
 packages/i18n/locales/en/app.json                  |  17 ++-
 packages/i18n/locales/es/app.json                  |  17 ++-
 packages/i18n/locales/fr/app.json                  |  17 ++-
 packages/i18n/locales/ko/app.json                  |  17 ++-
 packages/i18n/locales/pt-BR/app.json               |  17 ++-
 packages/i18n/locales/zh-CN/app.json               |  17 ++-
 packages/i18n/locales/zh-TW/app.json               |  17 ++-
 packages/i18n/src/resources.d.ts                   |  28 ++++-
 29 files changed, 623 insertions(+), 90 deletions(-)

Fusion-Task-Id: FN-9040

Fusion-Task-Lineage: 29a803a2-0784-4a99-b7cb-2f67d105254b

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-08-13 17:22:46 -07:00

173 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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";
import "./DatabaseBackupsSection.css";
const formatBackupSize = (size: number): string => size > 1024 * 1024
? `${(size / (1024 * 1024)).toFixed(1)} MB`
: `${(size / 1024).toFixed(1)} KB`;
const formatBackupDate = (value: string): string => {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
};
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>
<details className="settings-advanced-disclosure">
<summary>{t("settings.database.advanced", "Advanced database settings")}</summary>
<SettingsNumberRow
descriptor={{
key: "embeddedPostgresMaxConnections",
label: t("settings.database.embeddedConnectionCap", "Embedded PostgreSQL connection cap"),
help: t("settings.database.embeddedConnectionCapHelp", "Maximum server connections for Fusion's embedded PostgreSQL. Applies after restarting Fusion. Range: 32–2,000. Unset by default — Fusion picks 500, or 150 on Windows where each connection is a separate process and higher caps can crash backends. External PostgreSQL uses its provider's connection limit."),
scope: "global",
min: 32,
max: 2000,
placeholder: t("settings.database.embeddedConnectionCapPlaceholder", "auto"),
}}
value={form.embeddedPostgresMaxConnections ?? null}
onChange={(v) => setForm((f) => ({ ...f, embeddedPostgresMaxConnections: v ?? undefined }))}
error={form.embeddedPostgresMaxConnections !== undefined && (form.embeddedPostgresMaxConnections < 32 || form.embeddedPostgresMaxConnections > 2000)
? t("settings.database.embeddedConnectionCapError", "Enter a value between 32 and 2,000.")
: undefined}
/>
</details>
<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…")} /></div>) : backupInfo ? (<>
{/* FNXC:SettingsBackups 2026-08-13-23:51: The inventory must show both stored backups and central-routine evidence so a blank list cannot conceal a schedule failure. */}
<section className="backup-schedule" aria-labelledby="backup-schedule-heading">
<h5 id="backup-schedule-heading">{t("settings.backups.scheduleStatus", "Automatic backup schedule")}</h5>
<dl>
<div><dt>{t("settings.backups.status", "Status")}</dt><dd>{backupInfo.schedule.enabled ? t("settings.backups.on", "On") : t("settings.backups.off", "Off")}</dd></div>
<div><dt>{t("settings.backups.cron", "Cron")}</dt><dd><code>{backupInfo.schedule.cronExpression}</code></dd></div>
{backupInfo.schedule.nextRunAt && <div><dt>{t("settings.backups.nextRun", "Next run")}</dt><dd>{formatBackupDate(backupInfo.schedule.nextRunAt)}</dd></div>}
{backupInfo.schedule.lastRunAt && <div><dt>{t("settings.backups.lastRun", "Last run")}</dt><dd>{formatBackupDate(backupInfo.schedule.lastRunAt)} — {backupInfo.schedule.lastRunSucceeded ? t("settings.backups.succeeded", "Succeeded") : t("settings.backups.failed", "Failed")}</dd></div>}
{backupInfo.schedule.lastRunOutput && <div><dt>{t("settings.backups.lastOutput", "Last output")}</dt><dd className={backupInfo.schedule.lastRunSucceeded ? undefined : "backup-schedule-error"}>{backupInfo.schedule.lastRunOutput}</dd></div>}
{backupInfo.schedule.runCount !== undefined && <div><dt>{t("settings.backups.runCount", "Run count")}</dt><dd>{backupInfo.schedule.runCount}</dd></div>}
</dl>
{backupInfo.schedule.enabled && !backupInfo.schedule.routineRegistered && <div className="settings-notice settings-notice-warning" role="status">
{t("settings.backups.scheduleNotRegistered", "No backup schedule is registered. Restart Fusion or re-save Database Backup settings to register it.")}
</div>}
</section>
<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">{formatBackupSize(backupInfo.totalSize)}</span><span className="backup-stat-label">{t("settings.backups.totalSize", "total size")}</span></div>
</div>
{backupInfo.listError && <p className="backup-list-error" role="alert">{backupInfo.listError}</p>}
{backupInfo.count === 0 ? <p className="settings-empty-state">{t("settings.backups.noBackupsYet", "No backups yet — use Backup Now or enable automatic backups.")}</p> : <ul className="backup-list">
{backupInfo.backups.slice().sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt)).slice(0, 10).map((backup) => <li key={backup.filename}>
<code>{backup.filename}</code><time dateTime={backup.createdAt}>{formatBackupDate(backup.createdAt)}</time><span className="backup-size">{formatBackupSize(backup.size)}</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>}
</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;