feat(KB-327): add automatic database backup system
- Add backup settings to ProjectSettings with schedule, retention, and directory config - Create BackupManager with create, list, cleanup, and restore operations - Add CLI backup commands: --create, --list, --restore, --cleanup - Implement backup validation and automation sync for scheduled backups - Add dashboard backup settings UI with stats and 'Backup Now' button - Add backup API routes for settings management and manual operations - Include comprehensive backup tests and changeset for patch release - Document backup configuration and recovery in AGENTS.md
This commit is contained in:
@@ -1563,3 +1563,39 @@ export function fetchAgentHeartbeats(agentId: string, limit?: number): Promise<A
|
||||
const query = limit !== undefined ? `?limit=${limit}` : "";
|
||||
return api<AgentHeartbeatEvent[]>(`/agents/${encodeURIComponent(agentId)}/heartbeats${query}`);
|
||||
}
|
||||
|
||||
// --- Backup API ---
|
||||
|
||||
/** Backup metadata from the API */
|
||||
export interface BackupInfo {
|
||||
filename: string;
|
||||
createdAt: string;
|
||||
size: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
/** Result of listing backups */
|
||||
export interface BackupListResponse {
|
||||
backups: BackupInfo[];
|
||||
count: number;
|
||||
totalSize: number;
|
||||
}
|
||||
|
||||
/** Result of creating a backup */
|
||||
export interface BackupCreateResponse {
|
||||
success: boolean;
|
||||
backupPath?: string;
|
||||
output?: string;
|
||||
deletedCount?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Fetch all database backups */
|
||||
export function fetchBackups(): Promise<BackupListResponse> {
|
||||
return api<BackupListResponse>("/backups");
|
||||
}
|
||||
|
||||
/** Create a new database backup immediately */
|
||||
export function createBackup(): Promise<BackupCreateResponse> {
|
||||
return api<BackupCreateResponse>("/backups", { method: "POST" });
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { THINKING_LEVELS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS } from "@fusion/core";
|
||||
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset } from "@fusion/core";
|
||||
import { fetchSettings, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, testNtfyNotification } from "../api";
|
||||
import type { AuthProvider, ModelInfo } from "../api";
|
||||
import { fetchSettings, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, testNtfyNotification, fetchBackups, createBackup } from "../api";
|
||||
import type { AuthProvider, ModelInfo, BackupListResponse } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { ThemeSelector } from "./ThemeSelector";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
@@ -42,6 +42,7 @@ const SETTINGS_SECTIONS = [
|
||||
{ id: "worktrees", label: "Worktrees", scope: "project" as const },
|
||||
{ id: "commands", label: "Commands", scope: "project" as const },
|
||||
{ id: "merge", label: "Merge", scope: "project" as const },
|
||||
{ id: "backups", label: "Backups", scope: "project" as const },
|
||||
{ id: "notifications", label: "Notifications", scope: "global" as const },
|
||||
{ id: "authentication", label: "Authentication", scope: undefined },
|
||||
] as const;
|
||||
@@ -93,6 +94,10 @@ export function SettingsModal({
|
||||
const [presetDraft, setPresetDraft] = useState<ModelPreset | null>(null);
|
||||
const [presetIdTouched, setPresetIdTouched] = useState(false);
|
||||
|
||||
// Backup state
|
||||
const [backupInfo, setBackupInfo] = useState<BackupListResponse | null>(null);
|
||||
const [backupLoading, setBackupLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings()
|
||||
.then((s) => {
|
||||
@@ -125,6 +130,16 @@ export function SettingsModal({
|
||||
}
|
||||
}, [activeSection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSection === "backups") {
|
||||
setBackupLoading(true);
|
||||
fetchBackups()
|
||||
.then((info) => setBackupInfo(info))
|
||||
.catch(() => setBackupInfo(null))
|
||||
.finally(() => setBackupLoading(false));
|
||||
}
|
||||
}, [activeSection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSection === "authentication") {
|
||||
setAuthLoading(true);
|
||||
@@ -206,6 +221,25 @@ export function SettingsModal({
|
||||
}
|
||||
}, [addToast, form.ntfyEnabled, form.ntfyTopic]);
|
||||
|
||||
const handleBackupNow = useCallback(async () => {
|
||||
setBackupLoading(true);
|
||||
try {
|
||||
const result = await createBackup();
|
||||
if (result.success) {
|
||||
addToast("Backup created successfully", "success");
|
||||
// Refresh backup list
|
||||
const info = await fetchBackups();
|
||||
setBackupInfo(info);
|
||||
} else {
|
||||
addToast(result.error || "Failed to create backup", "error");
|
||||
}
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to create backup", "error");
|
||||
} finally {
|
||||
setBackupLoading(false);
|
||||
}
|
||||
}, [addToast]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
@@ -970,6 +1004,133 @@ export function SettingsModal({
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
case "backups":
|
||||
return (
|
||||
<>
|
||||
{renderScopeBanner()}
|
||||
<h4 className="settings-section-heading">Database Backups</h4>
|
||||
<div className="form-group">
|
||||
<label htmlFor="autoBackupEnabled" className="checkbox-label">
|
||||
<input
|
||||
id="autoBackupEnabled"
|
||||
type="checkbox"
|
||||
checked={form.autoBackupEnabled || false}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, autoBackupEnabled: e.target.checked }))
|
||||
}
|
||||
/>
|
||||
Enable automatic database backups
|
||||
</label>
|
||||
<small>When enabled, the database is backed up automatically on a schedule</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="autoBackupSchedule">Backup Schedule (Cron)</label>
|
||||
<input
|
||||
id="autoBackupSchedule"
|
||||
type="text"
|
||||
placeholder="0 2 * * *"
|
||||
value={form.autoBackupSchedule || "0 2 * * *"}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, autoBackupSchedule: e.target.value }))
|
||||
}
|
||||
disabled={!form.autoBackupEnabled}
|
||||
/>
|
||||
<small>
|
||||
Cron expression for backup timing. Default: 0 2 * * * (daily at 2 AM).
|
||||
Examples: 0 * * * * (hourly), 0 0 * * 0 (weekly), */15 * * * * (every 15 min)
|
||||
</small>
|
||||
{form.autoBackupSchedule && !/^[\s\d*,/-]+$/.test(form.autoBackupSchedule) && (
|
||||
<small className="field-error">Invalid cron expression format</small>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="autoBackupRetention">Retention Count</label>
|
||||
<input
|
||||
id="autoBackupRetention"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={form.autoBackupRetention || 7}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, autoBackupRetention: Number(e.target.value) }))
|
||||
}
|
||||
disabled={!form.autoBackupEnabled}
|
||||
/>
|
||||
<small>Number of backup files to keep (oldest are deleted first). Range: 1-100.</small>
|
||||
{form.autoBackupRetention !== undefined && (form.autoBackupRetention < 1 || form.autoBackupRetention > 100) && (
|
||||
<small className="field-error">Must be between 1 and 100</small>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="autoBackupDir">Backup Directory</label>
|
||||
<input
|
||||
id="autoBackupDir"
|
||||
type="text"
|
||||
placeholder=".kb/backups"
|
||||
value={form.autoBackupDir || ".kb/backups"}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, autoBackupDir: e.target.value }))
|
||||
}
|
||||
disabled={!form.autoBackupEnabled}
|
||||
/>
|
||||
<small>Directory for backup files, relative to project root</small>
|
||||
{form.autoBackupDir && form.autoBackupDir.includes("..") && (
|
||||
<small className="field-error">Path cannot contain parent directory traversal (..)</small>
|
||||
)}
|
||||
</div>
|
||||
{backupLoading ? (
|
||||
<div className="settings-empty-state">Loading backup info…</div>
|
||||
) : backupInfo ? (
|
||||
<div className="form-group">
|
||||
<label>Current Backups</label>
|
||||
<div className="backup-stats">
|
||||
<div className="backup-stat">
|
||||
<span className="backup-stat-value">{backupInfo.count}</span>
|
||||
<span className="backup-stat-label">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">total size</span>
|
||||
</div>
|
||||
</div>
|
||||
{backupInfo.backups.length > 0 && (
|
||||
<details className="backup-list">
|
||||
<summary>View {backupInfo.backups.length} 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>...and {backupInfo.backups.length - 10} more</em></li>
|
||||
)}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="form-group">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={handleBackupNow}
|
||||
disabled={backupLoading}
|
||||
>
|
||||
{backupLoading ? "Creating…" : "Backup Now"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
case "notifications":
|
||||
return (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user