diff --git a/changelog.md b/changelog.md index 7670194..be833b0 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,17 @@ # Changelog +## 2025-11-27 - 1.8.0 - feat(backup) +Add backup scheduling system with GFS retention, API and UI integration + +- Introduce backup scheduling subsystem (BackupScheduler) and integrate it into Onebox lifecycle (init & shutdown) +- Extend BackupManager.createBackup to accept schedule metadata (scheduleId) so scheduled runs are tracked +- Add GFS-style retention policy support (IRetentionPolicy + RETENTION_PRESETS) and expose per-tier retention in types +- Database migrations and repository changes: create backups and backup_schedules tables, add schedule_id, per-tier retention columns, and scope (all/pattern/service) support (migrations up to version 12) +- HTTP API: add backup schedule endpoints (GET/POST/PUT/DELETE /api/backup-schedules), trigger endpoint (/api/backup-schedules/:id/trigger), and service-scoped schedule endpoints +- UI: add API client methods for backup schedules and register a Backups tab in Services UI to surface schedules/backups +- Add task scheduling dependency (@push.rocks/taskbuffer) and export it via plugins.ts; update deno.json accordingly +- Type and repository updates across codebase to support schedule-aware backups, schedule CRUD, and retention enforcement + ## 2025-11-27 - 1.7.0 - feat(backup) Add backup system: BackupManager, DB schema, API endpoints and UI support diff --git a/deno.json b/deno.json index 59fc538..a246096 100644 --- a/deno.json +++ b/deno.json @@ -21,7 +21,8 @@ "@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@6.4.3", "@push.rocks/smartacme": "npm:@push.rocks/smartacme@^8.0.0", "@push.rocks/smartregistry": "npm:@push.rocks/smartregistry@^2.2.0", - "@push.rocks/smarts3": "npm:@push.rocks/smarts3@^5.1.0" + "@push.rocks/smarts3": "npm:@push.rocks/smarts3@^5.1.0", + "@push.rocks/taskbuffer": "npm:@push.rocks/taskbuffer@^3.1.0" }, "compilerOptions": { "lib": [ diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index a9811c2..3730176 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/onebox', - version: '1.7.0', + version: '1.8.0', description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers' } diff --git a/ts/classes/backup-manager.ts b/ts/classes/backup-manager.ts index d06abe0..77cd8eb 100644 --- a/ts/classes/backup-manager.ts +++ b/ts/classes/backup-manager.ts @@ -22,6 +22,8 @@ import type { IRestoreResult, TPlatformServiceType, IPlatformResource, + IBackupCreateOptions, + TRetentionTier, } from '../types.ts'; import { logger } from '../logging.ts'; import { getErrorMessage } from '../utils/error.ts'; @@ -45,8 +47,10 @@ export class BackupManager { /** * Create a backup for a service + * @param serviceName - Name of the service to backup + * @param options - Optional backup creation options (scheduleId) */ - async createBackup(serviceName: string): Promise { + async createBackup(serviceName: string, options?: IBackupCreateOptions): Promise { const service = this.oneboxRef.database.getServiceByName(serviceName); if (!service) { throw new Error(`Service not found: ${serviceName}`); @@ -181,6 +185,7 @@ export class BackupManager { includesImage: includeImage, platformResources: resourceTypes, checksum, + scheduleId: options?.scheduleId, }; const createdBackup = this.oneboxRef.database.createBackup(backup); diff --git a/ts/classes/backup-scheduler.ts b/ts/classes/backup-scheduler.ts new file mode 100644 index 0000000..beb4edb --- /dev/null +++ b/ts/classes/backup-scheduler.ts @@ -0,0 +1,650 @@ +/** + * Backup Scheduler for Onebox + * + * Uses @push.rocks/taskbuffer for cron-based scheduled backups + * with GFS (Grandfather-Father-Son) time-window based retention scheme. + */ + +import * as plugins from '../plugins.ts'; +import type { + IBackupSchedule, + IBackupScheduleCreate, + IBackupScheduleUpdate, + IService, + IRetentionPolicy, +} from '../types.ts'; +import { RETENTION_PRESETS } from '../types.ts'; +import { logger } from '../logging.ts'; +import { getErrorMessage } from '../utils/error.ts'; +import type { Onebox } from './onebox.ts'; + +export class BackupScheduler { + private oneboxRef: Onebox; + private taskManager!: plugins.taskbuffer.TaskManager; + private scheduledTasks: Map = new Map(); + private initialized = false; + + constructor(oneboxRef: Onebox) { + this.oneboxRef = oneboxRef; + // TaskManager is created in init() to avoid log spam before ready + } + + /** + * Initialize the scheduler and load enabled schedules + */ + async init(): Promise { + if (this.initialized) { + logger.warn('BackupScheduler already initialized'); + return; + } + + try { + // Create TaskManager here (not in constructor) to avoid "no cronjobs" log spam + this.taskManager = new plugins.taskbuffer.TaskManager(); + + // Add heartbeat task immediately to prevent "no cronjobs specified" log spam + // This runs hourly and does nothing, but keeps taskbuffer happy + const heartbeatTask = new plugins.taskbuffer.Task({ + name: 'backup-scheduler-heartbeat', + taskFunction: async () => { + // No-op heartbeat task + }, + }); + this.taskManager.addAndScheduleTask(heartbeatTask, '0 * * * *'); // Hourly + + // Load all enabled schedules from database + const schedules = this.oneboxRef.database.getEnabledBackupSchedules(); + + for (const schedule of schedules) { + await this.registerTask(schedule); + } + + // Start the task manager (activates cron scheduling) + await this.taskManager.start(); + + this.initialized = true; + logger.info(`Backup scheduler started with ${schedules.length} enabled schedule(s)`); + } catch (error) { + logger.error(`Failed to initialize backup scheduler: ${getErrorMessage(error)}`); + throw error; + } + } + + /** + * Stop the scheduler + */ + async stop(): Promise { + if (!this.initialized || !this.taskManager) return; + + try { + await this.taskManager.stop(); + this.scheduledTasks.clear(); + this.initialized = false; + logger.info('Backup scheduler stopped'); + } catch (error) { + logger.error(`Failed to stop backup scheduler: ${getErrorMessage(error)}`); + } + } + + /** + * Create a new backup schedule + */ + async createSchedule(request: IBackupScheduleCreate): Promise { + // Validate based on scope type + let serviceId: number | undefined; + let serviceName: string | undefined; + + switch (request.scopeType) { + case 'service': + // Validate service exists + if (!request.serviceName) { + throw new Error('serviceName is required for service-specific schedules'); + } + const service = this.oneboxRef.database.getServiceByName(request.serviceName); + if (!service) { + throw new Error(`Service not found: ${request.serviceName}`); + } + serviceId = service.id!; + serviceName = service.name; + break; + + case 'pattern': + // Validate pattern is provided + if (!request.scopePattern) { + throw new Error('scopePattern is required for pattern-based schedules'); + } + // Validate pattern matches at least one service + const matchingServices = this.getServicesMatchingPattern(request.scopePattern); + if (matchingServices.length === 0) { + logger.warn(`Pattern "${request.scopePattern}" currently matches no services`); + } + break; + + case 'all': + // No validation needed for global schedules + break; + + default: + throw new Error(`Invalid scope type: ${request.scopeType}`); + } + + // Use provided cron expression + const cronExpression = request.cronExpression; + + // Calculate next run time + const nextRunAt = this.calculateNextRun(cronExpression); + + // Create schedule in database + const schedule = this.oneboxRef.database.createBackupSchedule({ + scopeType: request.scopeType, + scopePattern: request.scopePattern, + serviceId, + serviceName, + cronExpression, + retention: request.retention, + enabled: request.enabled !== false, + lastRunAt: null, + nextRunAt, + lastStatus: null, + lastError: null, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + + // Register task if enabled + if (schedule.enabled) { + await this.registerTask(schedule); + } + + const scopeDesc = this.getScopeDescription(schedule); + const retentionDesc = this.getRetentionDescription(schedule.retention); + logger.info(`Backup schedule created: ${schedule.id} for ${scopeDesc} (${retentionDesc})`); + return schedule; + } + + /** + * Update an existing backup schedule + */ + async updateSchedule(scheduleId: number, updates: IBackupScheduleUpdate): Promise { + const schedule = this.oneboxRef.database.getBackupScheduleById(scheduleId); + if (!schedule) { + throw new Error(`Backup schedule not found: ${scheduleId}`); + } + + // Deschedule existing task if present + await this.descheduleTask(scheduleId); + + // Update database + this.oneboxRef.database.updateBackupSchedule(scheduleId, updates); + + // Get updated schedule + const updatedSchedule = this.oneboxRef.database.getBackupScheduleById(scheduleId)!; + + // Calculate new next run time if cron changed + if (updates.cronExpression) { + const nextRunAt = this.calculateNextRun(updatedSchedule.cronExpression); + this.oneboxRef.database.updateBackupSchedule(scheduleId, { nextRunAt }); + } + + // Re-register task if enabled + if (updatedSchedule.enabled) { + await this.registerTask(updatedSchedule); + } + + logger.info(`Backup schedule updated: ${scheduleId}`); + return this.oneboxRef.database.getBackupScheduleById(scheduleId)!; + } + + /** + * Delete a backup schedule + */ + async deleteSchedule(scheduleId: number): Promise { + const schedule = this.oneboxRef.database.getBackupScheduleById(scheduleId); + if (!schedule) { + throw new Error(`Backup schedule not found: ${scheduleId}`); + } + + // Deschedule task + await this.descheduleTask(scheduleId); + + // Delete from database + this.oneboxRef.database.deleteBackupSchedule(scheduleId); + + logger.info(`Backup schedule deleted: ${scheduleId}`); + } + + /** + * Trigger immediate backup for a schedule + */ + async triggerBackup(scheduleId: number): Promise { + const schedule = this.oneboxRef.database.getBackupScheduleById(scheduleId); + if (!schedule) { + throw new Error(`Backup schedule not found: ${scheduleId}`); + } + + logger.info(`Manually triggering backup for schedule ${scheduleId}`); + await this.executeBackup(schedule); + } + + /** + * Get all schedules + */ + getAllSchedules(): IBackupSchedule[] { + return this.oneboxRef.database.getAllBackupSchedules(); + } + + /** + * Get schedule by ID + */ + getScheduleById(id: number): IBackupSchedule | null { + return this.oneboxRef.database.getBackupScheduleById(id); + } + + /** + * Get schedules for a service + */ + getSchedulesForService(serviceName: string): IBackupSchedule[] { + const service = this.oneboxRef.database.getServiceByName(serviceName); + if (!service) { + return []; + } + return this.oneboxRef.database.getBackupSchedulesByService(service.id!); + } + + /** + * Get retention presets + */ + getRetentionPresets(): typeof RETENTION_PRESETS { + return RETENTION_PRESETS; + } + + // ========== Private Methods ========== + + /** + * Register a task for a schedule + */ + private async registerTask(schedule: IBackupSchedule): Promise { + const taskName = `backup-${schedule.id}`; + + const task = new plugins.taskbuffer.Task({ + name: taskName, + taskFunction: async () => { + await this.executeBackup(schedule); + }, + }); + + // Add and schedule the task + this.taskManager.addAndScheduleTask(task, schedule.cronExpression); + this.scheduledTasks.set(schedule.id!, task); + + // Update next run time in database + this.updateNextRunTime(schedule.id!); + + logger.debug(`Registered backup task: ${taskName} with cron: ${schedule.cronExpression}`); + } + + /** + * Deschedule a task + */ + private async descheduleTask(scheduleId: number): Promise { + const task = this.scheduledTasks.get(scheduleId); + if (task) { + await this.taskManager.descheduleTask(task); + this.scheduledTasks.delete(scheduleId); + logger.debug(`Descheduled backup task for schedule ${scheduleId}`); + } + } + + /** + * Execute a backup for a schedule + */ + private async executeBackup(schedule: IBackupSchedule): Promise { + const scopeDesc = this.getScopeDescription(schedule); + const servicesToBackup = this.getServicesForSchedule(schedule); + + if (servicesToBackup.length === 0) { + logger.warn(`No services to backup for schedule ${schedule.id} (${scopeDesc})`); + this.oneboxRef.database.updateBackupSchedule(schedule.id!, { + lastRunAt: Date.now(), + lastStatus: 'success', + lastError: 'No matching services found', + }); + this.updateNextRunTime(schedule.id!); + return; + } + + const retentionDesc = this.getRetentionDescription(schedule.retention); + logger.info(`Executing scheduled backup for ${scopeDesc}: ${servicesToBackup.length} service(s) (${retentionDesc})`); + + let successCount = 0; + let failCount = 0; + const errors: string[] = []; + + for (const service of servicesToBackup) { + try { + // Create backup with schedule ID + await this.oneboxRef.backupManager.createBackup(service.name, { + scheduleId: schedule.id, + }); + + // Apply time-window based retention policy for this service + await this.applyRetention(schedule, service.id!); + + successCount++; + logger.success(`Scheduled backup completed for ${service.name}`); + } catch (error) { + const errorMessage = getErrorMessage(error); + logger.error(`Scheduled backup failed for ${service.name}: ${errorMessage}`); + errors.push(`${service.name}: ${errorMessage}`); + failCount++; + } + } + + // Update schedule status + const lastStatus = failCount === 0 ? 'success' : 'failed'; + const lastError = errors.length > 0 ? errors.join('; ') : null; + + this.oneboxRef.database.updateBackupSchedule(schedule.id!, { + lastRunAt: Date.now(), + lastStatus, + lastError, + }); + + if (failCount === 0) { + logger.success(`Scheduled backup completed for ${scopeDesc}: ${successCount} service(s)`); + } else { + logger.warn(`Scheduled backup partially failed for ${scopeDesc}: ${successCount} succeeded, ${failCount} failed`); + } + + // Update next run time + this.updateNextRunTime(schedule.id!); + } + + /** + * Apply time-window based retention policy + * Works correctly regardless of backup frequency (cron schedule) + */ + private async applyRetention(schedule: IBackupSchedule, serviceId: number): Promise { + // Get all backups for this schedule and service + const allBackups = this.oneboxRef.database.getBackupsByService(serviceId); + const backups = allBackups.filter(b => b.scheduleId === schedule.id); + + if (backups.length === 0) { + return; + } + + const { hourly, daily, weekly, monthly } = schedule.retention; + const now = Date.now(); + const toKeep = new Set(); + + // Hourly: Keep up to N most recent backups from last 24 hours + if (hourly > 0) { + const recentBackups = backups + .filter(b => now - b.createdAt < 24 * 60 * 60 * 1000) + .sort((a, b) => b.createdAt - a.createdAt) + .slice(0, hourly); + recentBackups.forEach(b => toKeep.add(b.id!)); + } + + // Daily: Keep oldest backup per day for last N days + if (daily > 0) { + for (let i = 0; i < daily; i++) { + const dayStart = this.getStartOfDay(now, i); + const dayEnd = dayStart + 24 * 60 * 60 * 1000; + const dayBackups = backups.filter(b => + b.createdAt >= dayStart && b.createdAt < dayEnd + ); + if (dayBackups.length > 0) { + // Keep oldest from this day (most representative) + const oldest = dayBackups.sort((a, b) => a.createdAt - b.createdAt)[0]; + toKeep.add(oldest.id!); + } + } + } + + // Weekly: Keep oldest backup per week for last N weeks + if (weekly > 0) { + for (let i = 0; i < weekly; i++) { + const weekStart = this.getStartOfWeek(now, i); + const weekEnd = weekStart + 7 * 24 * 60 * 60 * 1000; + const weekBackups = backups.filter(b => + b.createdAt >= weekStart && b.createdAt < weekEnd + ); + if (weekBackups.length > 0) { + const oldest = weekBackups.sort((a, b) => a.createdAt - b.createdAt)[0]; + toKeep.add(oldest.id!); + } + } + } + + // Monthly: Keep oldest backup per month for last N months + if (monthly > 0) { + for (let i = 0; i < monthly; i++) { + const { start, end } = this.getMonthRange(now, i); + const monthBackups = backups.filter(b => + b.createdAt >= start && b.createdAt < end + ); + if (monthBackups.length > 0) { + const oldest = monthBackups.sort((a, b) => a.createdAt - b.createdAt)[0]; + toKeep.add(oldest.id!); + } + } + } + + // Delete anything not in toKeep + for (const backup of backups) { + if (!toKeep.has(backup.id!)) { + try { + await this.oneboxRef.backupManager.deleteBackup(backup.id!); + logger.info(`Deleted backup ${backup.filename} (retention policy)`); + } catch (error) { + logger.warn(`Failed to delete old backup ${backup.filename}: ${getErrorMessage(error)}`); + } + } + } + } + + /** + * Get start of day (midnight) for N days ago + */ + private getStartOfDay(now: number, daysAgo: number): number { + const date = new Date(now); + date.setDate(date.getDate() - daysAgo); + date.setHours(0, 0, 0, 0); + return date.getTime(); + } + + /** + * Get start of week (Sunday midnight) for N weeks ago + */ + private getStartOfWeek(now: number, weeksAgo: number): number { + const date = new Date(now); + date.setDate(date.getDate() - (weeksAgo * 7) - date.getDay()); + date.setHours(0, 0, 0, 0); + return date.getTime(); + } + + /** + * Get month range for N months ago + */ + private getMonthRange(now: number, monthsAgo: number): { start: number; end: number } { + const date = new Date(now); + date.setMonth(date.getMonth() - monthsAgo); + date.setDate(1); + date.setHours(0, 0, 0, 0); + const start = date.getTime(); + + date.setMonth(date.getMonth() + 1); + const end = date.getTime(); + + return { start, end }; + } + + /** + * Update next run time for a schedule + */ + private updateNextRunTime(scheduleId: number): void { + const schedule = this.oneboxRef.database.getBackupScheduleById(scheduleId); + if (!schedule) return; + + const nextRunAt = this.calculateNextRun(schedule.cronExpression); + this.oneboxRef.database.updateBackupSchedule(scheduleId, { nextRunAt }); + } + + /** + * Calculate next run time from cron expression + */ + private calculateNextRun(cronExpression: string): number { + try { + // Get next scheduled runs from task manager + const scheduledTasks = this.taskManager.getScheduledTasks(); + + // Find our task and get its next run + for (const taskInfo of scheduledTasks) { + if (taskInfo.schedule === cronExpression && taskInfo.nextRun) { + return taskInfo.nextRun.getTime(); + } + } + + // Fallback: parse cron and calculate next occurrence + // Simple implementation for common patterns + const now = new Date(); + const parts = cronExpression.split(' '); + + if (parts.length === 5) { + const [minute, hour, dayOfMonth, month, dayOfWeek] = parts; + + // For daily schedules (e.g., "0 2 * * *") + if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*') { + const nextRun = new Date(now); + nextRun.setHours(parseInt(hour), parseInt(minute), 0, 0); + if (nextRun <= now) { + nextRun.setDate(nextRun.getDate() + 1); + } + return nextRun.getTime(); + } + + // For weekly schedules (e.g., "0 2 * * 0") + if (dayOfMonth === '*' && month === '*' && dayOfWeek !== '*') { + const targetDay = parseInt(dayOfWeek); + const nextRun = new Date(now); + nextRun.setHours(parseInt(hour), parseInt(minute), 0, 0); + const currentDay = now.getDay(); + let daysUntilTarget = (targetDay - currentDay + 7) % 7; + if (daysUntilTarget === 0 && nextRun <= now) { + daysUntilTarget = 7; + } + nextRun.setDate(nextRun.getDate() + daysUntilTarget); + return nextRun.getTime(); + } + + // For monthly schedules (e.g., "0 2 1 * *") + if (dayOfMonth !== '*' && month === '*' && dayOfWeek === '*') { + const targetDay = parseInt(dayOfMonth); + const nextRun = new Date(now); + nextRun.setDate(targetDay); + nextRun.setHours(parseInt(hour), parseInt(minute), 0, 0); + if (nextRun <= now) { + nextRun.setMonth(nextRun.getMonth() + 1); + } + return nextRun.getTime(); + } + + // For yearly schedules (e.g., "0 2 1 1 *") + if (dayOfMonth !== '*' && month !== '*' && dayOfWeek === '*') { + const targetMonth = parseInt(month) - 1; // JavaScript months are 0-indexed + const targetDay = parseInt(dayOfMonth); + const nextRun = new Date(now); + nextRun.setMonth(targetMonth, targetDay); + nextRun.setHours(parseInt(hour), parseInt(minute), 0, 0); + if (nextRun <= now) { + nextRun.setFullYear(nextRun.getFullYear() + 1); + } + return nextRun.getTime(); + } + } + + // Default: next day at 2 AM + const fallback = new Date(now); + fallback.setDate(fallback.getDate() + 1); + fallback.setHours(2, 0, 0, 0); + return fallback.getTime(); + } catch { + // On any error, return tomorrow at 2 AM + const fallback = new Date(); + fallback.setDate(fallback.getDate() + 1); + fallback.setHours(2, 0, 0, 0); + return fallback.getTime(); + } + } + + /** + * Get services that match a schedule based on its scope type + */ + private getServicesForSchedule(schedule: IBackupSchedule): IService[] { + const allServices = this.oneboxRef.database.getAllServices(); + + switch (schedule.scopeType) { + case 'all': + return allServices; + + case 'pattern': + if (!schedule.scopePattern) return []; + return this.getServicesMatchingPattern(schedule.scopePattern); + + case 'service': + if (!schedule.serviceId) return []; + const service = allServices.find(s => s.id === schedule.serviceId); + return service ? [service] : []; + + default: + return []; + } + } + + /** + * Get services that match a glob pattern + */ + private getServicesMatchingPattern(pattern: string): IService[] { + const allServices = this.oneboxRef.database.getAllServices(); + return allServices.filter(s => this.matchesGlobPattern(s.name, pattern)); + } + + /** + * Simple glob pattern matching (supports * and ?) + */ + private matchesGlobPattern(text: string, pattern: string): boolean { + // Convert glob pattern to regex + // Escape special regex characters except * and ? + const regexPattern = pattern + .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape special chars + .replace(/\*/g, '.*') // * matches any characters + .replace(/\?/g, '.'); // ? matches single character + + const regex = new RegExp(`^${regexPattern}$`, 'i'); + return regex.test(text); + } + + /** + * Get human-readable description of a schedule's scope + */ + private getScopeDescription(schedule: IBackupSchedule): string { + switch (schedule.scopeType) { + case 'all': + return 'all services'; + case 'pattern': + return `pattern "${schedule.scopePattern}"`; + case 'service': + return `service "${schedule.serviceName}"`; + default: + return 'unknown scope'; + } + } + + /** + * Get human-readable description of retention policy + */ + private getRetentionDescription(retention: IRetentionPolicy): string { + return `H:${retention.hourly} D:${retention.daily} W:${retention.weekly} M:${retention.monthly}`; + } +} diff --git a/ts/classes/httpserver.ts b/ts/classes/httpserver.ts index ff8d64a..2cc82f8 100644 --- a/ts/classes/httpserver.ts +++ b/ts/classes/httpserver.ts @@ -8,7 +8,15 @@ import * as plugins from '../plugins.ts'; import { logger } from '../logging.ts'; import { getErrorMessage } from '../utils/error.ts'; import type { Onebox } from './onebox.ts'; -import type { IApiResponse, ICreateRegistryTokenRequest, IRegistryTokenView, TPlatformServiceType, IContainerStats } from '../types.ts'; +import type { + IApiResponse, + ICreateRegistryTokenRequest, + IRegistryTokenView, + TPlatformServiceType, + IContainerStats, + IBackupScheduleCreate, + IBackupScheduleUpdate, +} from '../types.ts'; export class OneboxHttpServer { private oneboxRef: Onebox; @@ -343,6 +351,26 @@ export class OneboxHttpServer { return await this.handleSetBackupPasswordRequest(req); } else if (path === '/api/settings/backup-password' && method === 'GET') { return await this.handleCheckBackupPasswordRequest(); + // Backup Schedule endpoints + } else if (path === '/api/backup-schedules' && method === 'GET') { + return await this.handleListBackupSchedulesRequest(); + } else if (path === '/api/backup-schedules' && method === 'POST') { + return await this.handleCreateBackupScheduleRequest(req); + } else if (path.match(/^\/api\/backup-schedules\/\d+$/) && method === 'GET') { + const scheduleId = Number(path.split('/').pop()); + return await this.handleGetBackupScheduleRequest(scheduleId); + } else if (path.match(/^\/api\/backup-schedules\/\d+$/) && method === 'PUT') { + const scheduleId = Number(path.split('/').pop()); + return await this.handleUpdateBackupScheduleRequest(scheduleId, req); + } else if (path.match(/^\/api\/backup-schedules\/\d+$/) && method === 'DELETE') { + const scheduleId = Number(path.split('/').pop()); + return await this.handleDeleteBackupScheduleRequest(scheduleId); + } else if (path.match(/^\/api\/backup-schedules\/\d+\/trigger$/) && method === 'POST') { + const scheduleId = Number(path.split('/')[3]); + return await this.handleTriggerBackupScheduleRequest(scheduleId); + } else if (path.match(/^\/api\/services\/[^/]+\/backup-schedules$/) && method === 'GET') { + const serviceName = path.split('/')[3]; + return await this.handleListServiceBackupSchedulesRequest(serviceName); } else { return this.jsonResponse({ success: false, error: 'Not found' }, 404); } @@ -2311,6 +2339,244 @@ export class OneboxHttpServer { } } + // ============ Backup Schedule Endpoints ============ + + /** + * List all backup schedules + */ + private async handleListBackupSchedulesRequest(): Promise { + try { + const schedules = this.oneboxRef.backupScheduler.getAllSchedules(); + return this.jsonResponse({ success: true, data: schedules }); + } catch (error) { + logger.error(`Failed to list backup schedules: ${getErrorMessage(error)}`); + return this.jsonResponse({ + success: false, + error: getErrorMessage(error) || 'Failed to list backup schedules', + }, 500); + } + } + + /** + * Create a new backup schedule + */ + private async handleCreateBackupScheduleRequest(req: Request): Promise { + try { + const body = await req.json() as IBackupScheduleCreate; + + // Validate scope type + if (!body.scopeType) { + return this.jsonResponse({ + success: false, + error: 'Scope type is required (all, pattern, or service)', + }, 400); + } + + if (!['all', 'pattern', 'service'].includes(body.scopeType)) { + return this.jsonResponse({ + success: false, + error: 'Invalid scope type. Must be: all, pattern, or service', + }, 400); + } + + // Validate scope-specific requirements + if (body.scopeType === 'service' && !body.serviceName) { + return this.jsonResponse({ + success: false, + error: 'Service name is required for service-specific schedules', + }, 400); + } + + if (body.scopeType === 'pattern' && !body.scopePattern) { + return this.jsonResponse({ + success: false, + error: 'Scope pattern is required for pattern-based schedules', + }, 400); + } + + if (!body.cronExpression) { + return this.jsonResponse({ + success: false, + error: 'Cron expression is required', + }, 400); + } + + if (!body.retention) { + return this.jsonResponse({ + success: false, + error: 'Retention policy is required', + }, 400); + } + + // Validate retention policy + const { hourly, daily, weekly, monthly } = body.retention; + if (typeof hourly !== 'number' || typeof daily !== 'number' || + typeof weekly !== 'number' || typeof monthly !== 'number') { + return this.jsonResponse({ + success: false, + error: 'Retention policy must have hourly, daily, weekly, and monthly as numbers', + }, 400); + } + + if (hourly < 0 || daily < 0 || weekly < 0 || monthly < 0) { + return this.jsonResponse({ + success: false, + error: 'Retention values must be non-negative', + }, 400); + } + + const schedule = await this.oneboxRef.backupScheduler.createSchedule(body); + + // Build descriptive message based on scope type + let scopeDesc: string; + switch (body.scopeType) { + case 'all': + scopeDesc = 'all services'; + break; + case 'pattern': + scopeDesc = `pattern '${body.scopePattern}'`; + break; + case 'service': + scopeDesc = `service '${body.serviceName}'`; + break; + } + + return this.jsonResponse({ + success: true, + message: `Backup schedule created for ${scopeDesc}`, + data: schedule, + }); + } catch (error) { + logger.error(`Failed to create backup schedule: ${getErrorMessage(error)}`); + return this.jsonResponse({ + success: false, + error: getErrorMessage(error) || 'Failed to create backup schedule', + }, 500); + } + } + + /** + * Get a specific backup schedule + */ + private async handleGetBackupScheduleRequest(scheduleId: number): Promise { + try { + const schedule = this.oneboxRef.backupScheduler.getScheduleById(scheduleId); + if (!schedule) { + return this.jsonResponse({ success: false, error: 'Backup schedule not found' }, 404); + } + + return this.jsonResponse({ success: true, data: schedule }); + } catch (error) { + logger.error(`Failed to get backup schedule ${scheduleId}: ${getErrorMessage(error)}`); + return this.jsonResponse({ + success: false, + error: getErrorMessage(error) || 'Failed to get backup schedule', + }, 500); + } + } + + /** + * Update a backup schedule + */ + private async handleUpdateBackupScheduleRequest(scheduleId: number, req: Request): Promise { + try { + const body = await req.json() as IBackupScheduleUpdate; + + // Validate retention policy if provided + if (body.retention) { + const { hourly, daily, weekly, monthly } = body.retention; + if (typeof hourly !== 'number' || typeof daily !== 'number' || + typeof weekly !== 'number' || typeof monthly !== 'number') { + return this.jsonResponse({ + success: false, + error: 'Retention policy must have hourly, daily, weekly, and monthly as numbers', + }, 400); + } + if (hourly < 0 || daily < 0 || weekly < 0 || monthly < 0) { + return this.jsonResponse({ + success: false, + error: 'Retention values must be non-negative', + }, 400); + } + } + + const schedule = await this.oneboxRef.backupScheduler.updateSchedule(scheduleId, body); + + return this.jsonResponse({ + success: true, + message: 'Backup schedule updated', + data: schedule, + }); + } catch (error) { + logger.error(`Failed to update backup schedule ${scheduleId}: ${getErrorMessage(error)}`); + return this.jsonResponse({ + success: false, + error: getErrorMessage(error) || 'Failed to update backup schedule', + }, 500); + } + } + + /** + * Delete a backup schedule + */ + private async handleDeleteBackupScheduleRequest(scheduleId: number): Promise { + try { + await this.oneboxRef.backupScheduler.deleteSchedule(scheduleId); + + return this.jsonResponse({ + success: true, + message: 'Backup schedule deleted', + }); + } catch (error) { + logger.error(`Failed to delete backup schedule ${scheduleId}: ${getErrorMessage(error)}`); + return this.jsonResponse({ + success: false, + error: getErrorMessage(error) || 'Failed to delete backup schedule', + }, 500); + } + } + + /** + * Trigger immediate backup for a schedule + */ + private async handleTriggerBackupScheduleRequest(scheduleId: number): Promise { + try { + await this.oneboxRef.backupScheduler.triggerBackup(scheduleId); + + return this.jsonResponse({ + success: true, + message: 'Backup triggered successfully', + }); + } catch (error) { + logger.error(`Failed to trigger backup for schedule ${scheduleId}: ${getErrorMessage(error)}`); + return this.jsonResponse({ + success: false, + error: getErrorMessage(error) || 'Failed to trigger backup', + }, 500); + } + } + + /** + * List backup schedules for a specific service + */ + private async handleListServiceBackupSchedulesRequest(serviceName: string): Promise { + try { + const service = this.oneboxRef.services.getService(serviceName); + if (!service) { + return this.jsonResponse({ success: false, error: 'Service not found' }, 404); + } + + const schedules = this.oneboxRef.backupScheduler.getSchedulesForService(serviceName); + return this.jsonResponse({ success: true, data: schedules }); + } catch (error) { + logger.error(`Failed to list backup schedules for service ${serviceName}: ${getErrorMessage(error)}`); + return this.jsonResponse({ + success: false, + error: getErrorMessage(error) || 'Failed to list backup schedules', + }, 500); + } + } + /** * Helper to create JSON response */ diff --git a/ts/classes/onebox.ts b/ts/classes/onebox.ts index 250e360..91c727c 100644 --- a/ts/classes/onebox.ts +++ b/ts/classes/onebox.ts @@ -21,6 +21,7 @@ import { RegistryManager } from './registry.ts'; import { PlatformServicesManager } from './platform-services/index.ts'; import { CaddyLogReceiver } from './caddy-log-receiver.ts'; import { BackupManager } from './backup-manager.ts'; +import { BackupScheduler } from './backup-scheduler.ts'; export class Onebox { public database: OneboxDatabase; @@ -38,6 +39,7 @@ export class Onebox { public platformServices: PlatformServicesManager; public caddyLogReceiver: CaddyLogReceiver; public backupManager: BackupManager; + public backupScheduler: BackupScheduler; private initialized = false; @@ -72,6 +74,9 @@ export class Onebox { // Initialize Backup manager this.backupManager = new BackupManager(this); + + // Initialize Backup scheduler + this.backupScheduler = new BackupScheduler(this); } /** @@ -166,6 +171,14 @@ export class Onebox { // Start auto-update monitoring for registry services this.services.startAutoUpdateMonitoring(); + // Initialize Backup Scheduler (non-critical) + try { + await this.backupScheduler.init(); + } catch (error) { + logger.warn('Backup scheduler initialization failed - scheduled backups will be disabled'); + logger.warn(`Error: ${getErrorMessage(error)}`); + } + this.initialized = true; logger.success('Onebox initialized successfully'); } catch (error) { @@ -337,6 +350,9 @@ export class Onebox { try { logger.info('Shutting down Onebox...'); + // Stop backup scheduler + await this.backupScheduler.stop(); + // Stop daemon if running await this.daemon.stop(); diff --git a/ts/database/index.ts b/ts/database/index.ts index 2faf135..891d86e 100644 --- a/ts/database/index.ts +++ b/ts/database/index.ts @@ -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 { + 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); + } } diff --git a/ts/database/repositories/backup.repository.ts b/ts/database/repositories/backup.repository.ts index f20e998..3dafb5f 100644 --- a/ts/database/repositories/backup.repository.ts +++ b/ts/database/repositories/backup.repository.ts @@ -1,18 +1,27 @@ /** * Backup Repository - * Handles CRUD operations for backups table + * Handles CRUD operations for backups and backup_schedules tables */ import { BaseRepository } from '../base.repository.ts'; -import type { IBackup, TPlatformServiceType } from '../../types.ts'; +import type { + IBackup, + IBackupSchedule, + IBackupScheduleUpdate, + TPlatformServiceType, + TBackupScheduleScope, + IRetentionPolicy, +} from '../../types.ts'; export class BackupRepository extends BaseRepository { + // ============ Backup CRUD ============ + create(backup: Omit): IBackup { this.query( `INSERT INTO backups ( service_id, service_name, filename, size_bytes, created_at, - includes_image, platform_resources, checksum - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + includes_image, platform_resources, checksum, schedule_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ backup.serviceId, backup.serviceName, @@ -22,6 +31,7 @@ export class BackupRepository extends BaseRepository { backup.includesImage ? 1 : 0, JSON.stringify(backup.platformResources), backup.checksum, + backup.scheduleId ?? null, ] ); @@ -60,6 +70,14 @@ export class BackupRepository extends BaseRepository { this.query('DELETE FROM backups WHERE service_id = ?', [serviceId]); } + getBySchedule(scheduleId: number): IBackup[] { + const rows = this.query( + 'SELECT * FROM backups WHERE schedule_id = ? ORDER BY created_at DESC', + [scheduleId] + ); + return rows.map((row) => this.rowToBackup(row)); + } + private rowToBackup(row: any): IBackup { let platformResources: TPlatformServiceType[] = []; const platformResourcesRaw = row.platform_resources; @@ -81,6 +99,151 @@ export class BackupRepository extends BaseRepository { includesImage: Boolean(row.includes_image), platformResources, checksum: String(row.checksum), + scheduleId: row.schedule_id ? Number(row.schedule_id) : undefined, + }; + } + + // ============ Backup Schedule CRUD ============ + + createSchedule(schedule: Omit): IBackupSchedule { + const now = Date.now(); + this.query( + `INSERT INTO backup_schedules ( + 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 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + schedule.scopeType, + schedule.scopePattern ?? null, + schedule.serviceId ?? null, + schedule.serviceName ?? null, + schedule.cronExpression, + schedule.retention.hourly, + schedule.retention.daily, + schedule.retention.weekly, + schedule.retention.monthly, + schedule.enabled ? 1 : 0, + schedule.lastRunAt, + schedule.nextRunAt, + schedule.lastStatus, + schedule.lastError, + now, + now, + ] + ); + + // Get the created schedule by looking for the most recent one with matching scope + const rows = this.query( + 'SELECT * FROM backup_schedules WHERE scope_type = ? AND cron_expression = ? ORDER BY id DESC LIMIT 1', + [schedule.scopeType, schedule.cronExpression] + ); + + return this.rowToSchedule(rows[0]); + } + + getScheduleById(id: number): IBackupSchedule | null { + const rows = this.query('SELECT * FROM backup_schedules WHERE id = ?', [id]); + return rows.length > 0 ? this.rowToSchedule(rows[0]) : null; + } + + getSchedulesByService(serviceId: number): IBackupSchedule[] { + const rows = this.query( + 'SELECT * FROM backup_schedules WHERE service_id = ? ORDER BY created_at DESC', + [serviceId] + ); + return rows.map((row) => this.rowToSchedule(row)); + } + + getEnabledSchedules(): IBackupSchedule[] { + const rows = this.query( + 'SELECT * FROM backup_schedules WHERE enabled = 1 ORDER BY next_run_at ASC' + ); + return rows.map((row) => this.rowToSchedule(row)); + } + + getAllSchedules(): IBackupSchedule[] { + const rows = this.query('SELECT * FROM backup_schedules ORDER BY created_at DESC'); + return rows.map((row) => this.rowToSchedule(row)); + } + + updateSchedule(id: number, updates: IBackupScheduleUpdate & { lastRunAt?: number; nextRunAt?: number; lastStatus?: 'success' | 'failed' | null; lastError?: string | null }): void { + const setClauses: string[] = []; + const params: (string | number | null)[] = []; + + if (updates.cronExpression !== undefined) { + setClauses.push('cron_expression = ?'); + params.push(updates.cronExpression); + } + if (updates.retention !== undefined) { + setClauses.push('retention_hourly = ?'); + params.push(updates.retention.hourly); + setClauses.push('retention_daily = ?'); + params.push(updates.retention.daily); + setClauses.push('retention_weekly = ?'); + params.push(updates.retention.weekly); + setClauses.push('retention_monthly = ?'); + params.push(updates.retention.monthly); + } + if (updates.enabled !== undefined) { + setClauses.push('enabled = ?'); + params.push(updates.enabled ? 1 : 0); + } + if (updates.lastRunAt !== undefined) { + setClauses.push('last_run_at = ?'); + params.push(updates.lastRunAt); + } + if (updates.nextRunAt !== undefined) { + setClauses.push('next_run_at = ?'); + params.push(updates.nextRunAt); + } + if (updates.lastStatus !== undefined) { + setClauses.push('last_status = ?'); + params.push(updates.lastStatus); + } + if (updates.lastError !== undefined) { + setClauses.push('last_error = ?'); + params.push(updates.lastError); + } + + if (setClauses.length === 0) return; + + setClauses.push('updated_at = ?'); + params.push(Date.now()); + params.push(id); + + this.query(`UPDATE backup_schedules SET ${setClauses.join(', ')} WHERE id = ?`, params); + } + + deleteSchedule(id: number): void { + this.query('DELETE FROM backup_schedules WHERE id = ?', [id]); + } + + deleteSchedulesByService(serviceId: number): void { + this.query('DELETE FROM backup_schedules WHERE service_id = ?', [serviceId]); + } + + private rowToSchedule(row: any): IBackupSchedule { + return { + id: Number(row.id), + scopeType: (String(row.scope_type) || 'service') as TBackupScheduleScope, + scopePattern: row.scope_pattern ? String(row.scope_pattern) : undefined, + serviceId: row.service_id ? Number(row.service_id) : undefined, + serviceName: row.service_name ? String(row.service_name) : undefined, + cronExpression: String(row.cron_expression), + retention: { + hourly: Number(row.retention_hourly ?? 0), + daily: Number(row.retention_daily ?? 7), + weekly: Number(row.retention_weekly ?? 4), + monthly: Number(row.retention_monthly ?? 12), + } as IRetentionPolicy, + enabled: Boolean(row.enabled), + lastRunAt: row.last_run_at ? Number(row.last_run_at) : null, + nextRunAt: row.next_run_at ? Number(row.next_run_at) : null, + lastStatus: row.last_status ? (String(row.last_status) as 'success' | 'failed') : null, + lastError: row.last_error ? String(row.last_error) : null, + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at), }; } } diff --git a/ts/plugins.ts b/ts/plugins.ts index f2e5eeb..b978d6c 100644 --- a/ts/plugins.ts +++ b/ts/plugins.ts @@ -41,6 +41,10 @@ export { smartregistry }; import * as smarts3 from '@push.rocks/smarts3'; export { smarts3 }; +// Task scheduling and cron jobs +import * as taskbuffer from '@push.rocks/taskbuffer'; +export { taskbuffer }; + // Crypto utilities (for password hashing, encryption) import * as bcrypt from 'https://deno.land/x/bcrypt@v0.4.1/mod.ts'; export { bcrypt }; diff --git a/ts/types.ts b/ts/types.ts index 154b640..d8a98c3 100644 --- a/ts/types.ts +++ b/ts/types.ts @@ -323,6 +323,24 @@ export interface ICliArgs { // Backup types export type TBackupRestoreMode = 'restore' | 'import' | 'clone'; +// Retention policy for GFS (Grandfather-Father-Son) time-window based retention +export interface IRetentionPolicy { + hourly: number; // 0 = disabled, else keep up to N backups from last 24h + daily: number; // Keep 1 backup per day for last N days + weekly: number; // Keep 1 backup per week for last N weeks + monthly: number; // Keep 1 backup per month for last N months +} + +// Default retention presets +export const RETENTION_PRESETS = { + standard: { hourly: 0, daily: 7, weekly: 4, monthly: 12 }, + frequent: { hourly: 24, daily: 7, weekly: 4, monthly: 12 }, + minimal: { hourly: 0, daily: 3, weekly: 2, monthly: 6 }, + longterm: { hourly: 0, daily: 14, weekly: 8, monthly: 24 }, +} as const; + +export type TRetentionPreset = keyof typeof RETENTION_PRESETS | 'custom'; + export interface IBackup { id?: number; serviceId: number; @@ -333,6 +351,8 @@ export interface IBackup { includesImage: boolean; platformResources: TPlatformServiceType[]; // Which platform types were backed up checksum: string; + // Scheduled backup fields + scheduleId?: number; // Links backup to its schedule for retention } export interface IBackupManifest { @@ -384,3 +404,43 @@ export interface IRestoreResult { platformResourcesRestored: number; warnings: string[]; } + +// Backup scheduling types (GFS retention scheme) +export type TBackupScheduleScope = 'all' | 'pattern' | 'service'; + +export interface IBackupSchedule { + id?: number; + scopeType: TBackupScheduleScope; + scopePattern?: string; // Glob pattern for 'pattern' scope type + serviceId?: number; // Only for 'service' scope type + serviceName?: string; // Only for 'service' scope type + cronExpression: string; + retention: IRetentionPolicy; // Per-tier retention counts + enabled: boolean; + lastRunAt: number | null; + nextRunAt: number | null; + lastStatus: 'success' | 'failed' | null; + lastError: string | null; + createdAt: number; + updatedAt: number; +} + +export interface IBackupScheduleCreate { + scopeType: TBackupScheduleScope; + scopePattern?: string; // Required for 'pattern' scope type + serviceName?: string; // Required for 'service' scope type + cronExpression: string; + retention: IRetentionPolicy; + enabled?: boolean; +} + +export interface IBackupScheduleUpdate { + cronExpression?: string; + retention?: IRetentionPolicy; + enabled?: boolean; +} + +// Backup creation options (for scheduled backups) +export interface IBackupCreateOptions { + scheduleId?: number; +} diff --git a/ui/src/app/core/services/api.service.ts b/ui/src/app/core/services/api.service.ts index 1587b06..cda7fc5 100644 --- a/ui/src/app/core/services/api.service.ts +++ b/ui/src/app/core/services/api.service.ts @@ -29,6 +29,9 @@ import { IRestoreOptions, IRestoreResult, IBackupPasswordStatus, + IBackupSchedule, + IBackupScheduleCreate, + IBackupScheduleUpdate, } from '../types/api.types'; @Injectable({ providedIn: 'root' }) @@ -260,4 +263,33 @@ export class ApiService { this.http.get>('/api/settings/backup-password') ); } + + // Backup Schedules + async getBackupSchedules(): Promise> { + return firstValueFrom(this.http.get>('/api/backup-schedules')); + } + + async getBackupSchedule(scheduleId: number): Promise> { + return firstValueFrom(this.http.get>(`/api/backup-schedules/${scheduleId}`)); + } + + async createBackupSchedule(data: IBackupScheduleCreate): Promise> { + return firstValueFrom(this.http.post>('/api/backup-schedules', data)); + } + + async updateBackupSchedule(scheduleId: number, data: IBackupScheduleUpdate): Promise> { + return firstValueFrom(this.http.put>(`/api/backup-schedules/${scheduleId}`, data)); + } + + async deleteBackupSchedule(scheduleId: number): Promise> { + return firstValueFrom(this.http.delete>(`/api/backup-schedules/${scheduleId}`)); + } + + async triggerBackupSchedule(scheduleId: number): Promise> { + return firstValueFrom(this.http.post>(`/api/backup-schedules/${scheduleId}/trigger`, {})); + } + + async getServiceBackupSchedules(serviceName: string): Promise> { + return firstValueFrom(this.http.get>(`/api/services/${serviceName}/backup-schedules`)); + } } diff --git a/ui/src/app/core/types/api.types.ts b/ui/src/app/core/types/api.types.ts index e743d26..399d2fc 100644 --- a/ui/src/app/core/types/api.types.ts +++ b/ui/src/app/core/types/api.types.ts @@ -376,3 +376,61 @@ export interface IRestoreResult { export interface IBackupPasswordStatus { isConfigured: boolean; } + +// Backup Schedule Types +export type TBackupScheduleScope = 'all' | 'pattern' | 'service'; + +// Retention policy for GFS (Grandfather-Father-Son) time-window based retention +export interface IRetentionPolicy { + hourly: number; // 0 = disabled, else keep up to N backups from last 24h + daily: number; // Keep 1 backup per day for last N days + weekly: number; // Keep 1 backup per week for last N weeks + monthly: number; // Keep 1 backup per month for last N months +} + +// Default retention presets +export const RETENTION_PRESETS = { + standard: { hourly: 0, daily: 7, weekly: 4, monthly: 12 }, + frequent: { hourly: 24, daily: 7, weekly: 4, monthly: 12 }, + minimal: { hourly: 0, daily: 3, weekly: 2, monthly: 6 }, + longterm: { hourly: 0, daily: 14, weekly: 8, monthly: 24 }, +} as const; + +export type TRetentionPreset = keyof typeof RETENTION_PRESETS | 'custom'; + +export interface IBackupSchedule { + id?: number; + scopeType: TBackupScheduleScope; + scopePattern?: string; // Glob pattern for 'pattern' scope type + serviceId?: number; // Only for 'service' scope type + serviceName?: string; // Only for 'service' scope type + cronExpression: string; + retention: IRetentionPolicy; // Per-tier retention counts + enabled: boolean; + lastRunAt: number | null; + nextRunAt: number | null; + lastStatus: 'success' | 'failed' | null; + lastError: string | null; + createdAt: number; + updatedAt: number; +} + +export interface IBackupScheduleCreate { + scopeType: TBackupScheduleScope; + scopePattern?: string; // Required for 'pattern' scope type + serviceName?: string; // Required for 'service' scope type + cronExpression: string; + retention: IRetentionPolicy; + enabled?: boolean; +} + +export interface IBackupScheduleUpdate { + cronExpression?: string; + retention?: IRetentionPolicy; + enabled?: boolean; +} + +// Updated IBackup with schedule fields +export interface IBackupWithSchedule extends IBackup { + scheduleId?: number; +} diff --git a/ui/src/app/features/login/login.component.ts b/ui/src/app/features/login/login.component.ts index dc21022..52eb876 100644 --- a/ui/src/app/features/login/login.component.ts +++ b/ui/src/app/features/login/login.component.ts @@ -56,7 +56,7 @@ import { AlertComponent, AlertDescriptionComponent } from '../../ui/alert/alert. -
+
diff --git a/ui/src/app/features/services/backups-tab.component.ts b/ui/src/app/features/services/backups-tab.component.ts new file mode 100644 index 0000000..4632831 --- /dev/null +++ b/ui/src/app/features/services/backups-tab.component.ts @@ -0,0 +1,816 @@ +import { Component, inject, signal, computed, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ApiService } from '../../core/services/api.service'; +import { ToastService } from '../../core/services/toast.service'; +import { + IService, + IBackup, + IBackupSchedule, + IBackupScheduleCreate, + IRetentionPolicy, + TRetentionPreset, + RETENTION_PRESETS, +} from '../../core/types/api.types'; +import { + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardDescriptionComponent, + CardContentComponent, +} from '../../ui/card/card.component'; +import { ButtonComponent } from '../../ui/button/button.component'; +import { BadgeComponent } from '../../ui/badge/badge.component'; +import { + TableComponent, + TableHeaderComponent, + TableBodyComponent, + TableRowComponent, + TableHeadComponent, + TableCellComponent, +} from '../../ui/table/table.component'; +import { SkeletonComponent } from '../../ui/skeleton/skeleton.component'; +import { + DialogComponent, + DialogHeaderComponent, + DialogTitleComponent, + DialogDescriptionComponent, + DialogFooterComponent, +} from '../../ui/dialog/dialog.component'; +import { InputComponent } from '../../ui/input/input.component'; +import { LabelComponent } from '../../ui/label/label.component'; +import { SelectComponent, SelectOption } from '../../ui/select/select.component'; + +@Component({ + selector: 'app-backups-tab', + standalone: true, + host: { class: 'block' }, + imports: [ + CommonModule, + FormsModule, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardDescriptionComponent, + CardContentComponent, + ButtonComponent, + BadgeComponent, + TableComponent, + TableHeaderComponent, + TableBodyComponent, + TableRowComponent, + TableHeadComponent, + TableCellComponent, + SkeletonComponent, + DialogComponent, + DialogHeaderComponent, + DialogTitleComponent, + DialogDescriptionComponent, + DialogFooterComponent, + InputComponent, + LabelComponent, + SelectComponent, + ], + template: ` +
+ + + +
+
+ Backup Schedules + Configure automated backup schedules for your services +
+ +
+
+ + @if (schedulesLoading() && schedules().length === 0) { +
+ @for (_ of [1,2,3]; track $index) { + + } +
+ } @else if (schedules().length === 0) { +
+ + + +

No backup schedules

+

Create a schedule to automatically backup your services.

+
+ } @else { + + + + Scope + Retention + Schedule + Last Run + Next Run + Status + Actions + + + + @for (schedule of schedules(); track schedule.id) { + + {{ getScopeDisplay(schedule) }} + + {{ getRetentionDisplay(schedule.retention) }} + + + {{ schedule.cronExpression }} + + + {{ schedule.lastRunAt ? formatDate(schedule.lastRunAt) : 'Never' }} + + + {{ schedule.nextRunAt ? formatDate(schedule.nextRunAt) : '-' }} + + + @if (!schedule.enabled) { + Disabled + } @else if (schedule.lastStatus === 'success') { + Success + } @else if (schedule.lastStatus === 'failed') { + Failed + } @else { + Pending + } + + +
+ + + +
+
+
+ } +
+
+ } +
+
+ + + + + All Backups + Browse and manage all backups across services + + + @if (backupsLoading() && backups().length === 0) { +
+ @for (_ of [1,2,3]; track $index) { + + } +
+ } @else if (backups().length === 0) { +
+ + + +

No backups

+

Backups will appear here once created.

+
+ } @else { + + + + Service + Created + Size + Includes + Actions + + + + @for (backup of backups(); track backup.id) { + + {{ backup.serviceName }} + {{ formatDate(backup.createdAt) }} + {{ formatSize(backup.sizeBytes) }} + +
+ @if (backup.includesImage) { + Image + } + @for (resource of backup.platformResources; track resource) { + {{ resource }} + } + @if (!backup.includesImage && backup.platformResources.length === 0) { + Config only + } +
+
+ +
+ + + + + + +
+
+
+ } +
+
+ } +
+
+
+ + + + + Create Backup Schedule + + Set up an automated backup schedule. + + +
+ +
+ +
+ + + +
+
+ + + @if (newSchedule().scopeType === 'pattern') { +
+ + +

+ Use * to match any characters (e.g., test-* matches test-api, test-web) +

+
+ } + + + @if (newSchedule().scopeType === 'service') { +
+ + +
+ } + +
+ + +

+ e.g., "0 2 * * *" = daily at 2am, "0 2 * * 0" = weekly Sunday at 2am +

+
+ +
+ + +
+ +
+

Custom Retention (0 = disabled)

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + + + +
+ + + + + Delete Schedule + + Are you sure you want to delete this backup schedule for {{ scheduleToDelete() ? getScopeDisplay(scheduleToDelete()!) : '' }}? + This will not delete existing backups. + + + + + + + + + + + + Delete Backup + + Are you sure you want to delete this backup for "{{ backupToDelete()?.serviceName }}"? + This action cannot be undone. + + + + + + + + `, +}) +export class BackupsTabComponent implements OnInit { + private api = inject(ApiService); + private toast = inject(ToastService); + + // Data + services = signal([]); + schedules = signal([]); + backups = signal([]); + + // Loading states + schedulesLoading = signal(false); + backupsLoading = signal(false); + scheduleActionLoading = signal(null); + backupActionLoading = signal(null); + + // Dialog states + createScheduleDialogOpen = signal(false); + deleteScheduleDialogOpen = signal(false); + deleteBackupDialogOpen = signal(false); + + // Dialog data + newSchedule = signal>({ + scopeType: 'service', + cronExpression: '0 2 * * *', + retention: { ...RETENTION_PRESETS.standard }, + }); + newScheduleRetention = signal({ ...RETENTION_PRESETS.standard }); + selectedPreset = signal('standard'); + scheduleToDelete = signal(null); + backupToDelete = signal(null); + + // Computed options for selects + serviceOptions = computed(() => + this.services().map((s) => ({ label: s.name, value: s.name })) + ); + + presetOptions: SelectOption[] = [ + { label: 'Standard (H:0, D:7, W:4, M:12)', value: 'standard' }, + { label: 'Frequent (H:24, D:7, W:4, M:12)', value: 'frequent' }, + { label: 'Minimal (H:0, D:3, W:2, M:6)', value: 'minimal' }, + { label: 'Long-term (H:0, D:14, W:8, M:24)', value: 'longterm' }, + { label: 'Custom', value: 'custom' }, + ]; + + ngOnInit(): void { + this.loadServices(); + this.loadSchedules(); + this.loadBackups(); + } + + async loadServices(): Promise { + try { + const response = await this.api.getServices(); + if (response.success && response.data) { + this.services.set(response.data); + } + } catch { + // Silently fail - services needed for schedule creation + } + } + + async loadSchedules(): Promise { + this.schedulesLoading.set(true); + try { + const response = await this.api.getBackupSchedules(); + if (response.success && response.data) { + this.schedules.set(response.data); + } + } catch { + this.toast.error('Failed to load backup schedules'); + } finally { + this.schedulesLoading.set(false); + } + } + + async loadBackups(): Promise { + this.backupsLoading.set(true); + try { + const response = await this.api.getBackups(); + if (response.success && response.data) { + this.backups.set(response.data); + } + } catch { + this.toast.error('Failed to load backups'); + } finally { + this.backupsLoading.set(false); + } + } + + // Schedule actions + openCreateScheduleDialog(): void { + this.newSchedule.set({ + scopeType: 'service', + cronExpression: '0 2 * * *', + retention: { ...RETENTION_PRESETS.standard }, + }); + this.newScheduleRetention.set({ ...RETENTION_PRESETS.standard }); + this.selectedPreset.set('standard'); + this.createScheduleDialogOpen.set(true); + } + + updateNewSchedule(field: keyof IBackupScheduleCreate, value: string): void { + this.newSchedule.update((s) => ({ ...s, [field]: value })); + } + + selectPreset(preset: string): void { + this.selectedPreset.set(preset as TRetentionPreset); + if (preset !== 'custom' && preset in RETENTION_PRESETS) { + const retention = { ...RETENTION_PRESETS[preset as keyof typeof RETENTION_PRESETS] }; + this.newScheduleRetention.set(retention); + this.newSchedule.update((s) => ({ ...s, retention })); + } + } + + updateRetention(field: keyof IRetentionPolicy, value: number): void { + this.selectedPreset.set('custom'); + this.newScheduleRetention.update((r) => ({ ...r, [field]: value })); + this.newSchedule.update((s) => ({ + ...s, + retention: { ...this.newScheduleRetention() }, + })); + } + + async createSchedule(): Promise { + const schedule = this.newSchedule(); + if (!this.isCreateScheduleValid()) { + this.toast.error('Please fill in all required fields'); + return; + } + + // Ensure retention is set + const createRequest: IBackupScheduleCreate = { + scopeType: schedule.scopeType!, + scopePattern: schedule.scopePattern, + serviceName: schedule.serviceName, + cronExpression: schedule.cronExpression || '0 2 * * *', + retention: this.newScheduleRetention(), + enabled: schedule.enabled, + }; + + this.scheduleActionLoading.set(-1); // Use -1 for create action + try { + const response = await this.api.createBackupSchedule(createRequest); + if (response.success) { + const scopeDesc = this.getScheduleCreateScopeDisplay(schedule); + this.toast.success(`Backup schedule created for ${scopeDesc}`); + this.createScheduleDialogOpen.set(false); + this.loadSchedules(); + } else { + this.toast.error(response.error || 'Failed to create schedule'); + } + } catch { + this.toast.error('Failed to create schedule'); + } finally { + this.scheduleActionLoading.set(null); + } + } + + async triggerBackup(schedule: IBackupSchedule): Promise { + this.scheduleActionLoading.set(schedule.id!); + try { + const response = await this.api.triggerBackupSchedule(schedule.id!); + if (response.success) { + this.toast.success(`Backup triggered for ${this.getScopeDisplay(schedule)}`); + // Reload after a short delay to show the new backup + setTimeout(() => { + this.loadSchedules(); + this.loadBackups(); + }, 2000); + } else { + this.toast.error(response.error || 'Failed to trigger backup'); + } + } catch { + this.toast.error('Failed to trigger backup'); + } finally { + this.scheduleActionLoading.set(null); + } + } + + async toggleSchedule(schedule: IBackupSchedule): Promise { + this.scheduleActionLoading.set(schedule.id!); + try { + const response = await this.api.updateBackupSchedule(schedule.id!, { + enabled: !schedule.enabled, + }); + if (response.success) { + this.toast.success(`Schedule ${schedule.enabled ? 'disabled' : 'enabled'}`); + this.loadSchedules(); + } else { + this.toast.error(response.error || 'Failed to update schedule'); + } + } catch { + this.toast.error('Failed to update schedule'); + } finally { + this.scheduleActionLoading.set(null); + } + } + + confirmDeleteSchedule(schedule: IBackupSchedule): void { + this.scheduleToDelete.set(schedule); + this.deleteScheduleDialogOpen.set(true); + } + + async deleteSchedule(): Promise { + const schedule = this.scheduleToDelete(); + if (!schedule) return; + + this.scheduleActionLoading.set(schedule.id!); + try { + const response = await this.api.deleteBackupSchedule(schedule.id!); + if (response.success) { + this.toast.success('Backup schedule deleted'); + this.deleteScheduleDialogOpen.set(false); + this.loadSchedules(); + } else { + this.toast.error(response.error || 'Failed to delete schedule'); + } + } catch { + this.toast.error('Failed to delete schedule'); + } finally { + this.scheduleActionLoading.set(null); + this.scheduleToDelete.set(null); + } + } + + // Backup actions + getBackupDownloadUrl(backupId: number): string { + return this.api.getBackupDownloadUrl(backupId); + } + + confirmDeleteBackup(backup: IBackup): void { + this.backupToDelete.set(backup); + this.deleteBackupDialogOpen.set(true); + } + + async deleteBackup(): Promise { + const backup = this.backupToDelete(); + if (!backup) return; + + this.backupActionLoading.set(backup.id!); + try { + const response = await this.api.deleteBackup(backup.id!); + if (response.success) { + this.toast.success('Backup deleted'); + this.deleteBackupDialogOpen.set(false); + this.loadBackups(); + } else { + this.toast.error(response.error || 'Failed to delete backup'); + } + } catch { + this.toast.error('Failed to delete backup'); + } finally { + this.backupActionLoading.set(null); + this.backupToDelete.set(null); + } + } + + // Helpers + formatDate(timestamp: number): string { + return new Date(timestamp).toLocaleString(); + } + + formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; + } + + // Scope helpers + getScopeDisplay(schedule: IBackupSchedule): string { + switch (schedule.scopeType) { + case 'all': + return 'All Services'; + case 'pattern': + return `Pattern: ${schedule.scopePattern || ''}`; + case 'service': + return schedule.serviceName || 'Unknown Service'; + default: + return schedule.serviceName || 'Unknown'; + } + } + + getScheduleCreateScopeDisplay(schedule: Partial): string { + switch (schedule.scopeType) { + case 'all': + return 'all services'; + case 'pattern': + return `pattern "${schedule.scopePattern || ''}"`; + case 'service': + return `"${schedule.serviceName || ''}"`; + default: + return 'unknown scope'; + } + } + + isCreateScheduleValid(): boolean { + const schedule = this.newSchedule(); + const retention = this.newScheduleRetention(); + + // Check retention is valid (at least one tier has count > 0) + const hasRetention = retention.hourly > 0 || retention.daily > 0 || + retention.weekly > 0 || retention.monthly > 0; + if (!hasRetention) return false; + if (!schedule.scopeType) return false; + + switch (schedule.scopeType) { + case 'all': + return true; + case 'pattern': + return !!schedule.scopePattern && schedule.scopePattern.trim().length > 0; + case 'service': + return !!schedule.serviceName && schedule.serviceName.trim().length > 0; + default: + return false; + } + } + + // Retention display helpers + getRetentionDisplay(retention: IRetentionPolicy): string { + const parts: string[] = []; + if (retention.hourly > 0) parts.push(`H:${retention.hourly}`); + if (retention.daily > 0) parts.push(`D:${retention.daily}`); + if (retention.weekly > 0) parts.push(`W:${retention.weekly}`); + if (retention.monthly > 0) parts.push(`M:${retention.monthly}`); + return parts.length > 0 ? parts.join(', ') : 'None'; + } + + getRetentionTooltip(retention: IRetentionPolicy): string { + const parts: string[] = []; + if (retention.hourly > 0) parts.push(`${retention.hourly} hourly`); + if (retention.daily > 0) parts.push(`${retention.daily} daily`); + if (retention.weekly > 0) parts.push(`${retention.weekly} weekly`); + if (retention.monthly > 0) parts.push(`${retention.monthly} monthly`); + return parts.length > 0 ? `Keep: ${parts.join(', ')}` : 'No retention configured'; + } +} diff --git a/ui/src/app/features/services/services-list.component.ts b/ui/src/app/features/services/services-list.component.ts index 7354170..ff7dd04 100644 --- a/ui/src/app/features/services/services-list.component.ts +++ b/ui/src/app/features/services/services-list.component.ts @@ -31,8 +31,9 @@ import { DialogFooterComponent, } from '../../ui/dialog/dialog.component'; import { TabsComponent, TabComponent } from '../../ui/tabs/tabs.component'; +import { BackupsTabComponent } from './backups-tab.component'; -type TServicesTab = 'user' | 'system'; +type TServicesTab = 'user' | 'system' | 'backups'; @Component({ selector: 'app-services-list', @@ -60,6 +61,7 @@ type TServicesTab = 'user' | 'system'; DialogFooterComponent, TabsComponent, TabComponent, + BackupsTabComponent, ], template: `
@@ -85,6 +87,7 @@ type TServicesTab = 'user' | 'system'; User Services System Services + Backups @@ -280,6 +283,9 @@ type TServicesTab = 'user' | 'system'; } + @case ('backups') { + + } }
@@ -338,7 +344,7 @@ export class ServicesListComponent implements OnInit, OnDestroy { // Subscribe to route params to sync tab state with URL this.routeSub = this.route.paramMap.subscribe((params) => { const tab = params.get('tab') as TServicesTab; - if (tab && ['user', 'system'].includes(tab)) { + if (tab && ['user', 'system', 'backups'].includes(tab)) { this.activeTab.set(tab); } }); diff --git a/ui/src/app/ui/card/card.component.ts b/ui/src/app/ui/card/card.component.ts index 754d3b2..39a8806 100644 --- a/ui/src/app/ui/card/card.component.ts +++ b/ui/src/app/ui/card/card.component.ts @@ -30,7 +30,7 @@ export class CardComponent { export class CardHeaderComponent { @Input() class = ''; - private baseClasses = 'p-6'; + private baseClasses = 'block p-6'; get computedClasses(): string { return `${this.baseClasses} ${this.class}`.trim(); @@ -44,6 +44,7 @@ export class CardHeaderComponent { host: { '[class]': 'computedClasses', }, + styles: [':host { display: block; }'], }) export class CardTitleComponent { @Input() class = '';