feat(backup): Add backup scheduling system with GFS retention, API and UI integration

This commit is contained in:
2025-11-27 21:42:07 +00:00
parent c5d239ab28
commit 6ba7e655e3
17 changed files with 2319 additions and 12 deletions

View File

@@ -19,6 +19,8 @@ import type {
ICertificate,
ICertRequirement,
IBackup,
IBackupSchedule,
IBackupScheduleUpdate,
} from '../types.ts';
import type { TBindValue } from './types.ts';
import { logger } from '../logging.ts';
@@ -740,6 +742,183 @@ export class OneboxDatabase {
this.setMigrationVersion(9);
logger.success('Migration 9 completed: Backup system tables created');
}
// Migration 10: Backup schedules table and extend backups table
const version10 = this.getMigrationVersion();
if (version10 < 10) {
logger.info('Running migration 10: Creating backup schedules table...');
// Create backup_schedules table
this.query(`
CREATE TABLE backup_schedules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_id INTEGER NOT NULL,
service_name TEXT NOT NULL,
cron_expression TEXT NOT NULL,
retention_tier TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
last_run_at REAL,
next_run_at REAL,
last_status TEXT,
last_error TEXT,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
)
`);
this.query('CREATE INDEX IF NOT EXISTS idx_backup_schedules_service ON backup_schedules(service_id)');
this.query('CREATE INDEX IF NOT EXISTS idx_backup_schedules_enabled ON backup_schedules(enabled)');
// Extend backups table with retention_tier and schedule_id columns
this.query('ALTER TABLE backups ADD COLUMN retention_tier TEXT');
this.query('ALTER TABLE backups ADD COLUMN schedule_id INTEGER REFERENCES backup_schedules(id) ON DELETE SET NULL');
this.setMigrationVersion(10);
logger.success('Migration 10 completed: Backup schedules table created');
}
// Migration 11: Add scope columns for global/pattern backup schedules
const version11 = this.getMigrationVersion();
if (version11 < 11) {
logger.info('Running migration 11: Adding scope columns to backup_schedules...');
// Recreate backup_schedules table with nullable service_id/service_name and new scope columns
this.query(`
CREATE TABLE backup_schedules_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope_type TEXT NOT NULL DEFAULT 'service',
scope_pattern TEXT,
service_id INTEGER,
service_name TEXT,
cron_expression TEXT NOT NULL,
retention_tier TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
last_run_at REAL,
next_run_at REAL,
last_status TEXT,
last_error TEXT,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
)
`);
// Copy existing schedules (all are service-specific)
this.query(`
INSERT INTO backup_schedules_new (
id, scope_type, scope_pattern, service_id, service_name, cron_expression,
retention_tier, enabled, last_run_at, next_run_at, last_status, last_error,
created_at, updated_at
)
SELECT
id, 'service', NULL, service_id, service_name, cron_expression,
retention_tier, enabled, last_run_at, next_run_at, last_status, last_error,
created_at, updated_at
FROM backup_schedules
`);
this.query('DROP TABLE backup_schedules');
this.query('ALTER TABLE backup_schedules_new RENAME TO backup_schedules');
this.query('CREATE INDEX IF NOT EXISTS idx_backup_schedules_service ON backup_schedules(service_id)');
this.query('CREATE INDEX IF NOT EXISTS idx_backup_schedules_enabled ON backup_schedules(enabled)');
this.query('CREATE INDEX IF NOT EXISTS idx_backup_schedules_scope ON backup_schedules(scope_type)');
this.setMigrationVersion(11);
logger.success('Migration 11 completed: Scope columns added to backup_schedules');
}
// Migration 12: GFS retention policy - replace retention_tier with per-tier retention counts
const version12 = this.getMigrationVersion();
if (version12 < 12) {
logger.info('Running migration 12: Updating backup system for GFS retention policy...');
// Recreate backup_schedules table with new retention columns
this.query(`
CREATE TABLE backup_schedules_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope_type TEXT NOT NULL DEFAULT 'service',
scope_pattern TEXT,
service_id INTEGER,
service_name TEXT,
cron_expression TEXT NOT NULL,
retention_hourly INTEGER NOT NULL DEFAULT 0,
retention_daily INTEGER NOT NULL DEFAULT 7,
retention_weekly INTEGER NOT NULL DEFAULT 4,
retention_monthly INTEGER NOT NULL DEFAULT 12,
enabled INTEGER NOT NULL DEFAULT 1,
last_run_at REAL,
next_run_at REAL,
last_status TEXT,
last_error TEXT,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
)
`);
// Migrate existing data - convert old retention_tier to new format
// daily -> D:7, weekly -> W:4, monthly -> M:12, yearly -> M:12 (yearly becomes long monthly retention)
this.query(`
INSERT INTO backup_schedules_new (
id, scope_type, scope_pattern, service_id, service_name, cron_expression,
retention_hourly, retention_daily, retention_weekly, retention_monthly,
enabled, last_run_at, next_run_at, last_status, last_error, created_at, updated_at
)
SELECT
id, scope_type, scope_pattern, service_id, service_name, cron_expression,
0, -- retention_hourly
CASE WHEN retention_tier = 'daily' THEN 7 ELSE 0 END,
CASE WHEN retention_tier IN ('daily', 'weekly') THEN 4 ELSE 0 END,
CASE WHEN retention_tier IN ('daily', 'weekly', 'monthly') THEN 12
WHEN retention_tier = 'yearly' THEN 24 ELSE 12 END,
enabled, last_run_at, next_run_at, last_status, last_error, created_at, updated_at
FROM backup_schedules
`);
this.query('DROP TABLE backup_schedules');
this.query('ALTER TABLE backup_schedules_new RENAME TO backup_schedules');
this.query('CREATE INDEX IF NOT EXISTS idx_backup_schedules_service ON backup_schedules(service_id)');
this.query('CREATE INDEX IF NOT EXISTS idx_backup_schedules_enabled ON backup_schedules(enabled)');
this.query('CREATE INDEX IF NOT EXISTS idx_backup_schedules_scope ON backup_schedules(scope_type)');
// Recreate backups table without retention_tier column
this.query(`
CREATE TABLE backups_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_id INTEGER NOT NULL,
service_name TEXT NOT NULL,
filename TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
created_at REAL NOT NULL,
includes_image INTEGER NOT NULL,
platform_resources TEXT NOT NULL DEFAULT '[]',
checksum TEXT NOT NULL,
schedule_id INTEGER REFERENCES backup_schedules(id) ON DELETE SET NULL,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
)
`);
this.query(`
INSERT INTO backups_new (
id, service_id, service_name, filename, size_bytes, created_at,
includes_image, platform_resources, checksum, schedule_id
)
SELECT
id, service_id, service_name, filename, size_bytes, created_at,
includes_image, platform_resources, checksum, schedule_id
FROM backups
`);
this.query('DROP TABLE backups');
this.query('ALTER TABLE backups_new RENAME TO backups');
this.query('CREATE INDEX IF NOT EXISTS idx_backups_service ON backups(service_id)');
this.query('CREATE INDEX IF NOT EXISTS idx_backups_created ON backups(created_at DESC)');
this.query('CREATE INDEX IF NOT EXISTS idx_backups_schedule ON backups(schedule_id)');
this.setMigrationVersion(12);
logger.success('Migration 12 completed: GFS retention policy schema updated');
}
} catch (error) {
logger.error(`Migration failed: ${getErrorMessage(error)}`);
if (error instanceof Error && error.stack) {
@@ -1139,4 +1318,42 @@ export class OneboxDatabase {
deleteBackupsByService(serviceId: number): void {
this.backupRepo.deleteByService(serviceId);
}
getBackupsBySchedule(scheduleId: number): IBackup[] {
return this.backupRepo.getBySchedule(scheduleId);
}
// ============ Backup Schedules (delegated to repository) ============
createBackupSchedule(schedule: Omit<IBackupSchedule, 'id'>): IBackupSchedule {
return this.backupRepo.createSchedule(schedule);
}
getBackupScheduleById(id: number): IBackupSchedule | null {
return this.backupRepo.getScheduleById(id);
}
getBackupSchedulesByService(serviceId: number): IBackupSchedule[] {
return this.backupRepo.getSchedulesByService(serviceId);
}
getEnabledBackupSchedules(): IBackupSchedule[] {
return this.backupRepo.getEnabledSchedules();
}
getAllBackupSchedules(): IBackupSchedule[] {
return this.backupRepo.getAllSchedules();
}
updateBackupSchedule(id: number, updates: IBackupScheduleUpdate & { lastRunAt?: number; nextRunAt?: number; lastStatus?: 'success' | 'failed' | null; lastError?: string | null }): void {
this.backupRepo.updateSchedule(id, updates);
}
deleteBackupSchedule(id: number): void {
this.backupRepo.deleteSchedule(id);
}
deleteBackupSchedulesByService(serviceId: number): void {
this.backupRepo.deleteSchedulesByService(serviceId);
}
}