feat(backup): Add backup scheduling system with GFS retention, API and UI integration
This commit is contained in:
12
changelog.md
12
changelog.md
@@ -1,5 +1,17 @@
|
|||||||
# Changelog
|
# 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)
|
## 2025-11-27 - 1.7.0 - feat(backup)
|
||||||
Add backup system: BackupManager, DB schema, API endpoints and UI support
|
Add backup system: BackupManager, DB schema, API endpoints and UI support
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,8 @@
|
|||||||
"@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@6.4.3",
|
"@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@6.4.3",
|
||||||
"@push.rocks/smartacme": "npm:@push.rocks/smartacme@^8.0.0",
|
"@push.rocks/smartacme": "npm:@push.rocks/smartacme@^8.0.0",
|
||||||
"@push.rocks/smartregistry": "npm:@push.rocks/smartregistry@^2.2.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": {
|
"compilerOptions": {
|
||||||
"lib": [
|
"lib": [
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/onebox',
|
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'
|
description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import type {
|
|||||||
IRestoreResult,
|
IRestoreResult,
|
||||||
TPlatformServiceType,
|
TPlatformServiceType,
|
||||||
IPlatformResource,
|
IPlatformResource,
|
||||||
|
IBackupCreateOptions,
|
||||||
|
TRetentionTier,
|
||||||
} from '../types.ts';
|
} from '../types.ts';
|
||||||
import { logger } from '../logging.ts';
|
import { logger } from '../logging.ts';
|
||||||
import { getErrorMessage } from '../utils/error.ts';
|
import { getErrorMessage } from '../utils/error.ts';
|
||||||
@@ -45,8 +47,10 @@ export class BackupManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a backup for a service
|
* 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<IBackupResult> {
|
async createBackup(serviceName: string, options?: IBackupCreateOptions): Promise<IBackupResult> {
|
||||||
const service = this.oneboxRef.database.getServiceByName(serviceName);
|
const service = this.oneboxRef.database.getServiceByName(serviceName);
|
||||||
if (!service) {
|
if (!service) {
|
||||||
throw new Error(`Service not found: ${serviceName}`);
|
throw new Error(`Service not found: ${serviceName}`);
|
||||||
@@ -181,6 +185,7 @@ export class BackupManager {
|
|||||||
includesImage: includeImage,
|
includesImage: includeImage,
|
||||||
platformResources: resourceTypes,
|
platformResources: resourceTypes,
|
||||||
checksum,
|
checksum,
|
||||||
|
scheduleId: options?.scheduleId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const createdBackup = this.oneboxRef.database.createBackup(backup);
|
const createdBackup = this.oneboxRef.database.createBackup(backup);
|
||||||
|
|||||||
650
ts/classes/backup-scheduler.ts
Normal file
650
ts/classes/backup-scheduler.ts
Normal file
@@ -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<number, plugins.taskbuffer.Task> = 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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<IBackupSchedule> {
|
||||||
|
// 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<IBackupSchedule> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
// 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<number>();
|
||||||
|
|
||||||
|
// 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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,15 @@ import * as plugins from '../plugins.ts';
|
|||||||
import { logger } from '../logging.ts';
|
import { logger } from '../logging.ts';
|
||||||
import { getErrorMessage } from '../utils/error.ts';
|
import { getErrorMessage } from '../utils/error.ts';
|
||||||
import type { Onebox } from './onebox.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 {
|
export class OneboxHttpServer {
|
||||||
private oneboxRef: Onebox;
|
private oneboxRef: Onebox;
|
||||||
@@ -343,6 +351,26 @@ export class OneboxHttpServer {
|
|||||||
return await this.handleSetBackupPasswordRequest(req);
|
return await this.handleSetBackupPasswordRequest(req);
|
||||||
} else if (path === '/api/settings/backup-password' && method === 'GET') {
|
} else if (path === '/api/settings/backup-password' && method === 'GET') {
|
||||||
return await this.handleCheckBackupPasswordRequest();
|
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 {
|
} else {
|
||||||
return this.jsonResponse({ success: false, error: 'Not found' }, 404);
|
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<Response> {
|
||||||
|
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<Response> {
|
||||||
|
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<Response> {
|
||||||
|
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<Response> {
|
||||||
|
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<Response> {
|
||||||
|
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<Response> {
|
||||||
|
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<Response> {
|
||||||
|
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
|
* Helper to create JSON response
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { RegistryManager } from './registry.ts';
|
|||||||
import { PlatformServicesManager } from './platform-services/index.ts';
|
import { PlatformServicesManager } from './platform-services/index.ts';
|
||||||
import { CaddyLogReceiver } from './caddy-log-receiver.ts';
|
import { CaddyLogReceiver } from './caddy-log-receiver.ts';
|
||||||
import { BackupManager } from './backup-manager.ts';
|
import { BackupManager } from './backup-manager.ts';
|
||||||
|
import { BackupScheduler } from './backup-scheduler.ts';
|
||||||
|
|
||||||
export class Onebox {
|
export class Onebox {
|
||||||
public database: OneboxDatabase;
|
public database: OneboxDatabase;
|
||||||
@@ -38,6 +39,7 @@ export class Onebox {
|
|||||||
public platformServices: PlatformServicesManager;
|
public platformServices: PlatformServicesManager;
|
||||||
public caddyLogReceiver: CaddyLogReceiver;
|
public caddyLogReceiver: CaddyLogReceiver;
|
||||||
public backupManager: BackupManager;
|
public backupManager: BackupManager;
|
||||||
|
public backupScheduler: BackupScheduler;
|
||||||
|
|
||||||
private initialized = false;
|
private initialized = false;
|
||||||
|
|
||||||
@@ -72,6 +74,9 @@ export class Onebox {
|
|||||||
|
|
||||||
// Initialize Backup manager
|
// Initialize Backup manager
|
||||||
this.backupManager = new BackupManager(this);
|
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
|
// Start auto-update monitoring for registry services
|
||||||
this.services.startAutoUpdateMonitoring();
|
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;
|
this.initialized = true;
|
||||||
logger.success('Onebox initialized successfully');
|
logger.success('Onebox initialized successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -337,6 +350,9 @@ export class Onebox {
|
|||||||
try {
|
try {
|
||||||
logger.info('Shutting down Onebox...');
|
logger.info('Shutting down Onebox...');
|
||||||
|
|
||||||
|
// Stop backup scheduler
|
||||||
|
await this.backupScheduler.stop();
|
||||||
|
|
||||||
// Stop daemon if running
|
// Stop daemon if running
|
||||||
await this.daemon.stop();
|
await this.daemon.stop();
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import type {
|
|||||||
ICertificate,
|
ICertificate,
|
||||||
ICertRequirement,
|
ICertRequirement,
|
||||||
IBackup,
|
IBackup,
|
||||||
|
IBackupSchedule,
|
||||||
|
IBackupScheduleUpdate,
|
||||||
} from '../types.ts';
|
} from '../types.ts';
|
||||||
import type { TBindValue } from './types.ts';
|
import type { TBindValue } from './types.ts';
|
||||||
import { logger } from '../logging.ts';
|
import { logger } from '../logging.ts';
|
||||||
@@ -740,6 +742,183 @@ export class OneboxDatabase {
|
|||||||
this.setMigrationVersion(9);
|
this.setMigrationVersion(9);
|
||||||
logger.success('Migration 9 completed: Backup system tables created');
|
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) {
|
} catch (error) {
|
||||||
logger.error(`Migration failed: ${getErrorMessage(error)}`);
|
logger.error(`Migration failed: ${getErrorMessage(error)}`);
|
||||||
if (error instanceof Error && error.stack) {
|
if (error instanceof Error && error.stack) {
|
||||||
@@ -1139,4 +1318,42 @@ export class OneboxDatabase {
|
|||||||
deleteBackupsByService(serviceId: number): void {
|
deleteBackupsByService(serviceId: number): void {
|
||||||
this.backupRepo.deleteByService(serviceId);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,27 @@
|
|||||||
/**
|
/**
|
||||||
* Backup Repository
|
* Backup Repository
|
||||||
* Handles CRUD operations for backups table
|
* Handles CRUD operations for backups and backup_schedules tables
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { BaseRepository } from '../base.repository.ts';
|
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 {
|
export class BackupRepository extends BaseRepository {
|
||||||
|
// ============ Backup CRUD ============
|
||||||
|
|
||||||
create(backup: Omit<IBackup, 'id'>): IBackup {
|
create(backup: Omit<IBackup, 'id'>): IBackup {
|
||||||
this.query(
|
this.query(
|
||||||
`INSERT INTO backups (
|
`INSERT INTO backups (
|
||||||
service_id, service_name, filename, size_bytes, created_at,
|
service_id, service_name, filename, size_bytes, created_at,
|
||||||
includes_image, platform_resources, checksum
|
includes_image, platform_resources, checksum, schedule_id
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[
|
[
|
||||||
backup.serviceId,
|
backup.serviceId,
|
||||||
backup.serviceName,
|
backup.serviceName,
|
||||||
@@ -22,6 +31,7 @@ export class BackupRepository extends BaseRepository {
|
|||||||
backup.includesImage ? 1 : 0,
|
backup.includesImage ? 1 : 0,
|
||||||
JSON.stringify(backup.platformResources),
|
JSON.stringify(backup.platformResources),
|
||||||
backup.checksum,
|
backup.checksum,
|
||||||
|
backup.scheduleId ?? null,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -60,6 +70,14 @@ export class BackupRepository extends BaseRepository {
|
|||||||
this.query('DELETE FROM backups WHERE service_id = ?', [serviceId]);
|
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 {
|
private rowToBackup(row: any): IBackup {
|
||||||
let platformResources: TPlatformServiceType[] = [];
|
let platformResources: TPlatformServiceType[] = [];
|
||||||
const platformResourcesRaw = row.platform_resources;
|
const platformResourcesRaw = row.platform_resources;
|
||||||
@@ -81,6 +99,151 @@ export class BackupRepository extends BaseRepository {
|
|||||||
includesImage: Boolean(row.includes_image),
|
includesImage: Boolean(row.includes_image),
|
||||||
platformResources,
|
platformResources,
|
||||||
checksum: String(row.checksum),
|
checksum: String(row.checksum),
|
||||||
|
scheduleId: row.schedule_id ? Number(row.schedule_id) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Backup Schedule CRUD ============
|
||||||
|
|
||||||
|
createSchedule(schedule: Omit<IBackupSchedule, 'id'>): 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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ export { smartregistry };
|
|||||||
import * as smarts3 from '@push.rocks/smarts3';
|
import * as smarts3 from '@push.rocks/smarts3';
|
||||||
export { smarts3 };
|
export { smarts3 };
|
||||||
|
|
||||||
|
// Task scheduling and cron jobs
|
||||||
|
import * as taskbuffer from '@push.rocks/taskbuffer';
|
||||||
|
export { taskbuffer };
|
||||||
|
|
||||||
// Crypto utilities (for password hashing, encryption)
|
// Crypto utilities (for password hashing, encryption)
|
||||||
import * as bcrypt from 'https://deno.land/x/bcrypt@v0.4.1/mod.ts';
|
import * as bcrypt from 'https://deno.land/x/bcrypt@v0.4.1/mod.ts';
|
||||||
export { bcrypt };
|
export { bcrypt };
|
||||||
|
|||||||
60
ts/types.ts
60
ts/types.ts
@@ -323,6 +323,24 @@ export interface ICliArgs {
|
|||||||
// Backup types
|
// Backup types
|
||||||
export type TBackupRestoreMode = 'restore' | 'import' | 'clone';
|
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 {
|
export interface IBackup {
|
||||||
id?: number;
|
id?: number;
|
||||||
serviceId: number;
|
serviceId: number;
|
||||||
@@ -333,6 +351,8 @@ export interface IBackup {
|
|||||||
includesImage: boolean;
|
includesImage: boolean;
|
||||||
platformResources: TPlatformServiceType[]; // Which platform types were backed up
|
platformResources: TPlatformServiceType[]; // Which platform types were backed up
|
||||||
checksum: string;
|
checksum: string;
|
||||||
|
// Scheduled backup fields
|
||||||
|
scheduleId?: number; // Links backup to its schedule for retention
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IBackupManifest {
|
export interface IBackupManifest {
|
||||||
@@ -384,3 +404,43 @@ export interface IRestoreResult {
|
|||||||
platformResourcesRestored: number;
|
platformResourcesRestored: number;
|
||||||
warnings: string[];
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ import {
|
|||||||
IRestoreOptions,
|
IRestoreOptions,
|
||||||
IRestoreResult,
|
IRestoreResult,
|
||||||
IBackupPasswordStatus,
|
IBackupPasswordStatus,
|
||||||
|
IBackupSchedule,
|
||||||
|
IBackupScheduleCreate,
|
||||||
|
IBackupScheduleUpdate,
|
||||||
} from '../types/api.types';
|
} from '../types/api.types';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
@@ -260,4 +263,33 @@ export class ApiService {
|
|||||||
this.http.get<IApiResponse<IBackupPasswordStatus>>('/api/settings/backup-password')
|
this.http.get<IApiResponse<IBackupPasswordStatus>>('/api/settings/backup-password')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backup Schedules
|
||||||
|
async getBackupSchedules(): Promise<IApiResponse<IBackupSchedule[]>> {
|
||||||
|
return firstValueFrom(this.http.get<IApiResponse<IBackupSchedule[]>>('/api/backup-schedules'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBackupSchedule(scheduleId: number): Promise<IApiResponse<IBackupSchedule>> {
|
||||||
|
return firstValueFrom(this.http.get<IApiResponse<IBackupSchedule>>(`/api/backup-schedules/${scheduleId}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
async createBackupSchedule(data: IBackupScheduleCreate): Promise<IApiResponse<IBackupSchedule>> {
|
||||||
|
return firstValueFrom(this.http.post<IApiResponse<IBackupSchedule>>('/api/backup-schedules', data));
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateBackupSchedule(scheduleId: number, data: IBackupScheduleUpdate): Promise<IApiResponse<IBackupSchedule>> {
|
||||||
|
return firstValueFrom(this.http.put<IApiResponse<IBackupSchedule>>(`/api/backup-schedules/${scheduleId}`, data));
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteBackupSchedule(scheduleId: number): Promise<IApiResponse<void>> {
|
||||||
|
return firstValueFrom(this.http.delete<IApiResponse<void>>(`/api/backup-schedules/${scheduleId}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
async triggerBackupSchedule(scheduleId: number): Promise<IApiResponse<void>> {
|
||||||
|
return firstValueFrom(this.http.post<IApiResponse<void>>(`/api/backup-schedules/${scheduleId}/trigger`, {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getServiceBackupSchedules(serviceName: string): Promise<IApiResponse<IBackupSchedule[]>> {
|
||||||
|
return firstValueFrom(this.http.get<IApiResponse<IBackupSchedule[]>>(`/api/services/${serviceName}/backup-schedules`));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -376,3 +376,61 @@ export interface IRestoreResult {
|
|||||||
export interface IBackupPasswordStatus {
|
export interface IBackupPasswordStatus {
|
||||||
isConfigured: boolean;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ import { AlertComponent, AlertDescriptionComponent } from '../../ui/alert/alert.
|
|||||||
|
|
||||||
<ui-card class="w-full max-w-md">
|
<ui-card class="w-full max-w-md">
|
||||||
<ui-card-header class="text-center">
|
<ui-card-header class="text-center">
|
||||||
<div class="mx-auto mb-4">
|
<div class="flex justify-center mb-4">
|
||||||
<svg class="h-12 w-12 text-primary" viewBox="0 0 24 24" fill="currentColor">
|
<svg class="h-12 w-12 text-primary" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
816
ui/src/app/features/services/backups-tab.component.ts
Normal file
816
ui/src/app/features/services/backups-tab.component.ts
Normal file
@@ -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: `
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Backup Schedules Card -->
|
||||||
|
<ui-card>
|
||||||
|
<ui-card-header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<ui-card-title>Backup Schedules</ui-card-title>
|
||||||
|
<ui-card-description>Configure automated backup schedules for your services</ui-card-description>
|
||||||
|
</div>
|
||||||
|
<button uiButton (click)="openCreateScheduleDialog()">
|
||||||
|
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
Create Schedule
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ui-card-header>
|
||||||
|
<ui-card-content class="p-0">
|
||||||
|
@if (schedulesLoading() && schedules().length === 0) {
|
||||||
|
<div class="p-6 space-y-4">
|
||||||
|
@for (_ of [1,2,3]; track $index) {
|
||||||
|
<ui-skeleton class="h-12 w-full" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else if (schedules().length === 0) {
|
||||||
|
<div class="p-12 text-center">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<h3 class="mt-4 text-lg font-semibold">No backup schedules</h3>
|
||||||
|
<p class="mt-2 text-sm text-muted-foreground">Create a schedule to automatically backup your services.</p>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<ui-table>
|
||||||
|
<ui-table-header>
|
||||||
|
<ui-table-row>
|
||||||
|
<ui-table-head>Scope</ui-table-head>
|
||||||
|
<ui-table-head>Retention</ui-table-head>
|
||||||
|
<ui-table-head>Schedule</ui-table-head>
|
||||||
|
<ui-table-head>Last Run</ui-table-head>
|
||||||
|
<ui-table-head>Next Run</ui-table-head>
|
||||||
|
<ui-table-head>Status</ui-table-head>
|
||||||
|
<ui-table-head class="text-right">Actions</ui-table-head>
|
||||||
|
</ui-table-row>
|
||||||
|
</ui-table-header>
|
||||||
|
<ui-table-body>
|
||||||
|
@for (schedule of schedules(); track schedule.id) {
|
||||||
|
<ui-table-row>
|
||||||
|
<ui-table-cell class="font-medium">{{ getScopeDisplay(schedule) }}</ui-table-cell>
|
||||||
|
<ui-table-cell class="font-mono text-xs" [title]="getRetentionTooltip(schedule.retention)">
|
||||||
|
{{ getRetentionDisplay(schedule.retention) }}
|
||||||
|
</ui-table-cell>
|
||||||
|
<ui-table-cell class="text-muted-foreground font-mono text-xs">
|
||||||
|
{{ schedule.cronExpression }}
|
||||||
|
</ui-table-cell>
|
||||||
|
<ui-table-cell class="text-muted-foreground">
|
||||||
|
{{ schedule.lastRunAt ? formatDate(schedule.lastRunAt) : 'Never' }}
|
||||||
|
</ui-table-cell>
|
||||||
|
<ui-table-cell class="text-muted-foreground">
|
||||||
|
{{ schedule.nextRunAt ? formatDate(schedule.nextRunAt) : '-' }}
|
||||||
|
</ui-table-cell>
|
||||||
|
<ui-table-cell>
|
||||||
|
@if (!schedule.enabled) {
|
||||||
|
<ui-badge variant="secondary">Disabled</ui-badge>
|
||||||
|
} @else if (schedule.lastStatus === 'success') {
|
||||||
|
<ui-badge variant="success">Success</ui-badge>
|
||||||
|
} @else if (schedule.lastStatus === 'failed') {
|
||||||
|
<ui-badge variant="destructive" [title]="schedule.lastError || ''">Failed</ui-badge>
|
||||||
|
} @else {
|
||||||
|
<ui-badge variant="outline">Pending</ui-badge>
|
||||||
|
}
|
||||||
|
</ui-table-cell>
|
||||||
|
<ui-table-cell class="text-right">
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
uiButton
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
(click)="triggerBackup(schedule)"
|
||||||
|
[disabled]="scheduleActionLoading() === schedule.id"
|
||||||
|
title="Run backup now"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
uiButton
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
(click)="toggleSchedule(schedule)"
|
||||||
|
[disabled]="scheduleActionLoading() === schedule.id"
|
||||||
|
[title]="schedule.enabled ? 'Disable' : 'Enable'"
|
||||||
|
>
|
||||||
|
@if (schedule.enabled) {
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
} @else {
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
uiButton
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
(click)="confirmDeleteSchedule(schedule)"
|
||||||
|
[disabled]="scheduleActionLoading() === schedule.id"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ui-table-cell>
|
||||||
|
</ui-table-row>
|
||||||
|
}
|
||||||
|
</ui-table-body>
|
||||||
|
</ui-table>
|
||||||
|
}
|
||||||
|
</ui-card-content>
|
||||||
|
</ui-card>
|
||||||
|
|
||||||
|
<!-- All Backups Card -->
|
||||||
|
<ui-card>
|
||||||
|
<ui-card-header>
|
||||||
|
<ui-card-title>All Backups</ui-card-title>
|
||||||
|
<ui-card-description>Browse and manage all backups across services</ui-card-description>
|
||||||
|
</ui-card-header>
|
||||||
|
<ui-card-content class="p-0">
|
||||||
|
@if (backupsLoading() && backups().length === 0) {
|
||||||
|
<div class="p-6 space-y-4">
|
||||||
|
@for (_ of [1,2,3]; track $index) {
|
||||||
|
<ui-skeleton class="h-12 w-full" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else if (backups().length === 0) {
|
||||||
|
<div class="p-12 text-center">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
|
||||||
|
</svg>
|
||||||
|
<h3 class="mt-4 text-lg font-semibold">No backups</h3>
|
||||||
|
<p class="mt-2 text-sm text-muted-foreground">Backups will appear here once created.</p>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<ui-table>
|
||||||
|
<ui-table-header>
|
||||||
|
<ui-table-row>
|
||||||
|
<ui-table-head>Service</ui-table-head>
|
||||||
|
<ui-table-head>Created</ui-table-head>
|
||||||
|
<ui-table-head>Size</ui-table-head>
|
||||||
|
<ui-table-head>Includes</ui-table-head>
|
||||||
|
<ui-table-head class="text-right">Actions</ui-table-head>
|
||||||
|
</ui-table-row>
|
||||||
|
</ui-table-header>
|
||||||
|
<ui-table-body>
|
||||||
|
@for (backup of backups(); track backup.id) {
|
||||||
|
<ui-table-row>
|
||||||
|
<ui-table-cell class="font-medium">{{ backup.serviceName }}</ui-table-cell>
|
||||||
|
<ui-table-cell class="text-muted-foreground">{{ formatDate(backup.createdAt) }}</ui-table-cell>
|
||||||
|
<ui-table-cell class="text-muted-foreground">{{ formatSize(backup.sizeBytes) }}</ui-table-cell>
|
||||||
|
<ui-table-cell>
|
||||||
|
<div class="flex gap-1 flex-wrap">
|
||||||
|
@if (backup.includesImage) {
|
||||||
|
<ui-badge variant="outline">Image</ui-badge>
|
||||||
|
}
|
||||||
|
@for (resource of backup.platformResources; track resource) {
|
||||||
|
<ui-badge variant="secondary">{{ resource }}</ui-badge>
|
||||||
|
}
|
||||||
|
@if (!backup.includesImage && backup.platformResources.length === 0) {
|
||||||
|
<span class="text-muted-foreground text-sm">Config only</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</ui-table-cell>
|
||||||
|
<ui-table-cell class="text-right">
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
<a
|
||||||
|
[href]="getBackupDownloadUrl(backup.id!)"
|
||||||
|
download
|
||||||
|
class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-8 px-3"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
uiButton
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
(click)="confirmDeleteBackup(backup)"
|
||||||
|
[disabled]="backupActionLoading() === backup.id"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ui-table-cell>
|
||||||
|
</ui-table-row>
|
||||||
|
}
|
||||||
|
</ui-table-body>
|
||||||
|
</ui-table>
|
||||||
|
}
|
||||||
|
</ui-card-content>
|
||||||
|
</ui-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Schedule Dialog -->
|
||||||
|
<ui-dialog [open]="createScheduleDialogOpen()" (openChange)="createScheduleDialogOpen.set($event)">
|
||||||
|
<ui-dialog-header>
|
||||||
|
<ui-dialog-title>Create Backup Schedule</ui-dialog-title>
|
||||||
|
<ui-dialog-description>
|
||||||
|
Set up an automated backup schedule.
|
||||||
|
</ui-dialog-description>
|
||||||
|
</ui-dialog-header>
|
||||||
|
<div class="space-y-4 py-4">
|
||||||
|
<!-- Scope Type Selection -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label uiLabel>Scope</label>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="scopeType"
|
||||||
|
value="all"
|
||||||
|
[checked]="newSchedule().scopeType === 'all'"
|
||||||
|
(change)="updateNewSchedule('scopeType', 'all')"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
<span class="text-sm">All Services (global)</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="scopeType"
|
||||||
|
value="pattern"
|
||||||
|
[checked]="newSchedule().scopeType === 'pattern'"
|
||||||
|
(change)="updateNewSchedule('scopeType', 'pattern')"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
<span class="text-sm">Pattern (glob)</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="scopeType"
|
||||||
|
value="service"
|
||||||
|
[checked]="newSchedule().scopeType === 'service'"
|
||||||
|
(change)="updateNewSchedule('scopeType', 'service')"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
<span class="text-sm">Specific Service</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pattern input (shown for pattern scope) -->
|
||||||
|
@if (newSchedule().scopeType === 'pattern') {
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label uiLabel>Pattern</label>
|
||||||
|
<input
|
||||||
|
uiInput
|
||||||
|
[value]="newSchedule().scopePattern || ''"
|
||||||
|
(input)="updateNewSchedule('scopePattern', $any($event.target).value)"
|
||||||
|
placeholder="test-*, prod-*, *-service"
|
||||||
|
/>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Use * to match any characters (e.g., test-* matches test-api, test-web)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Service select (shown for service scope) -->
|
||||||
|
@if (newSchedule().scopeType === 'service') {
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label uiLabel>Service</label>
|
||||||
|
<ui-select
|
||||||
|
[options]="serviceOptions()"
|
||||||
|
[value]="newSchedule().serviceName || ''"
|
||||||
|
(valueChange)="updateNewSchedule('serviceName', $event)"
|
||||||
|
placeholder="Select a service..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label uiLabel>Cron Expression</label>
|
||||||
|
<input
|
||||||
|
uiInput
|
||||||
|
[value]="newSchedule().cronExpression || '0 2 * * *'"
|
||||||
|
(input)="updateNewSchedule('cronExpression', $any($event.target).value)"
|
||||||
|
placeholder="0 2 * * *"
|
||||||
|
/>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
e.g., "0 2 * * *" = daily at 2am, "0 2 * * 0" = weekly Sunday at 2am
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label uiLabel>Retention Preset</label>
|
||||||
|
<ui-select
|
||||||
|
[options]="presetOptions"
|
||||||
|
[value]="selectedPreset()"
|
||||||
|
(valueChange)="selectPreset($event)"
|
||||||
|
placeholder="Select preset..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3 p-3 border rounded-md bg-muted/30">
|
||||||
|
<p class="text-sm font-medium">Custom Retention (0 = disabled)</p>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label uiLabel class="text-xs">Hourly</label>
|
||||||
|
<input
|
||||||
|
uiInput
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
[value]="newScheduleRetention().hourly"
|
||||||
|
(input)="updateRetention('hourly', +$any($event.target).value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label uiLabel class="text-xs">Daily</label>
|
||||||
|
<input
|
||||||
|
uiInput
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
[value]="newScheduleRetention().daily"
|
||||||
|
(input)="updateRetention('daily', +$any($event.target).value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label uiLabel class="text-xs">Weekly</label>
|
||||||
|
<input
|
||||||
|
uiInput
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
[value]="newScheduleRetention().weekly"
|
||||||
|
(input)="updateRetention('weekly', +$any($event.target).value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label uiLabel class="text-xs">Monthly</label>
|
||||||
|
<input
|
||||||
|
uiInput
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
[value]="newScheduleRetention().monthly"
|
||||||
|
(input)="updateRetention('monthly', +$any($event.target).value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ui-dialog-footer>
|
||||||
|
<button uiButton variant="outline" (click)="createScheduleDialogOpen.set(false)">Cancel</button>
|
||||||
|
<button
|
||||||
|
uiButton
|
||||||
|
(click)="createSchedule()"
|
||||||
|
[disabled]="!isCreateScheduleValid() || scheduleActionLoading() !== null"
|
||||||
|
>
|
||||||
|
Create Schedule
|
||||||
|
</button>
|
||||||
|
</ui-dialog-footer>
|
||||||
|
</ui-dialog>
|
||||||
|
|
||||||
|
<!-- Delete Schedule Confirmation Dialog -->
|
||||||
|
<ui-dialog [open]="deleteScheduleDialogOpen()" (openChange)="deleteScheduleDialogOpen.set($event)">
|
||||||
|
<ui-dialog-header>
|
||||||
|
<ui-dialog-title>Delete Schedule</ui-dialog-title>
|
||||||
|
<ui-dialog-description>
|
||||||
|
Are you sure you want to delete this backup schedule for {{ scheduleToDelete() ? getScopeDisplay(scheduleToDelete()!) : '' }}?
|
||||||
|
This will not delete existing backups.
|
||||||
|
</ui-dialog-description>
|
||||||
|
</ui-dialog-header>
|
||||||
|
<ui-dialog-footer>
|
||||||
|
<button uiButton variant="outline" (click)="deleteScheduleDialogOpen.set(false)">Cancel</button>
|
||||||
|
<button uiButton variant="destructive" (click)="deleteSchedule()" [disabled]="scheduleActionLoading() !== null">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</ui-dialog-footer>
|
||||||
|
</ui-dialog>
|
||||||
|
|
||||||
|
<!-- Delete Backup Confirmation Dialog -->
|
||||||
|
<ui-dialog [open]="deleteBackupDialogOpen()" (openChange)="deleteBackupDialogOpen.set($event)">
|
||||||
|
<ui-dialog-header>
|
||||||
|
<ui-dialog-title>Delete Backup</ui-dialog-title>
|
||||||
|
<ui-dialog-description>
|
||||||
|
Are you sure you want to delete this backup for "{{ backupToDelete()?.serviceName }}"?
|
||||||
|
This action cannot be undone.
|
||||||
|
</ui-dialog-description>
|
||||||
|
</ui-dialog-header>
|
||||||
|
<ui-dialog-footer>
|
||||||
|
<button uiButton variant="outline" (click)="deleteBackupDialogOpen.set(false)">Cancel</button>
|
||||||
|
<button uiButton variant="destructive" (click)="deleteBackup()" [disabled]="backupActionLoading() !== null">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</ui-dialog-footer>
|
||||||
|
</ui-dialog>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class BackupsTabComponent implements OnInit {
|
||||||
|
private api = inject(ApiService);
|
||||||
|
private toast = inject(ToastService);
|
||||||
|
|
||||||
|
// Data
|
||||||
|
services = signal<IService[]>([]);
|
||||||
|
schedules = signal<IBackupSchedule[]>([]);
|
||||||
|
backups = signal<IBackup[]>([]);
|
||||||
|
|
||||||
|
// Loading states
|
||||||
|
schedulesLoading = signal(false);
|
||||||
|
backupsLoading = signal(false);
|
||||||
|
scheduleActionLoading = signal<number | null>(null);
|
||||||
|
backupActionLoading = signal<number | null>(null);
|
||||||
|
|
||||||
|
// Dialog states
|
||||||
|
createScheduleDialogOpen = signal(false);
|
||||||
|
deleteScheduleDialogOpen = signal(false);
|
||||||
|
deleteBackupDialogOpen = signal(false);
|
||||||
|
|
||||||
|
// Dialog data
|
||||||
|
newSchedule = signal<Partial<IBackupScheduleCreate>>({
|
||||||
|
scopeType: 'service',
|
||||||
|
cronExpression: '0 2 * * *',
|
||||||
|
retention: { ...RETENTION_PRESETS.standard },
|
||||||
|
});
|
||||||
|
newScheduleRetention = signal<IRetentionPolicy>({ ...RETENTION_PRESETS.standard });
|
||||||
|
selectedPreset = signal<TRetentionPreset>('standard');
|
||||||
|
scheduleToDelete = signal<IBackupSchedule | null>(null);
|
||||||
|
backupToDelete = signal<IBackup | null>(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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<IBackupScheduleCreate>): 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,8 +31,9 @@ import {
|
|||||||
DialogFooterComponent,
|
DialogFooterComponent,
|
||||||
} from '../../ui/dialog/dialog.component';
|
} from '../../ui/dialog/dialog.component';
|
||||||
import { TabsComponent, TabComponent } from '../../ui/tabs/tabs.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({
|
@Component({
|
||||||
selector: 'app-services-list',
|
selector: 'app-services-list',
|
||||||
@@ -60,6 +61,7 @@ type TServicesTab = 'user' | 'system';
|
|||||||
DialogFooterComponent,
|
DialogFooterComponent,
|
||||||
TabsComponent,
|
TabsComponent,
|
||||||
TabComponent,
|
TabComponent,
|
||||||
|
BackupsTabComponent,
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
@@ -85,6 +87,7 @@ type TServicesTab = 'user' | 'system';
|
|||||||
<ui-tabs class="block">
|
<ui-tabs class="block">
|
||||||
<ui-tab [active]="activeTab() === 'user'" (tabClick)="setTab('user')">User Services</ui-tab>
|
<ui-tab [active]="activeTab() === 'user'" (tabClick)="setTab('user')">User Services</ui-tab>
|
||||||
<ui-tab [active]="activeTab() === 'system'" (tabClick)="setTab('system')">System Services</ui-tab>
|
<ui-tab [active]="activeTab() === 'system'" (tabClick)="setTab('system')">System Services</ui-tab>
|
||||||
|
<ui-tab [active]="activeTab() === 'backups'" (tabClick)="setTab('backups')">Backups</ui-tab>
|
||||||
</ui-tabs>
|
</ui-tabs>
|
||||||
|
|
||||||
<!-- Tab Content -->
|
<!-- Tab Content -->
|
||||||
@@ -280,6 +283,9 @@ type TServicesTab = 'user' | 'system';
|
|||||||
</ui-card-content>
|
</ui-card-content>
|
||||||
</ui-card>
|
</ui-card>
|
||||||
}
|
}
|
||||||
|
@case ('backups') {
|
||||||
|
<app-backups-tab />
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -338,7 +344,7 @@ export class ServicesListComponent implements OnInit, OnDestroy {
|
|||||||
// Subscribe to route params to sync tab state with URL
|
// Subscribe to route params to sync tab state with URL
|
||||||
this.routeSub = this.route.paramMap.subscribe((params) => {
|
this.routeSub = this.route.paramMap.subscribe((params) => {
|
||||||
const tab = params.get('tab') as TServicesTab;
|
const tab = params.get('tab') as TServicesTab;
|
||||||
if (tab && ['user', 'system'].includes(tab)) {
|
if (tab && ['user', 'system', 'backups'].includes(tab)) {
|
||||||
this.activeTab.set(tab);
|
this.activeTab.set(tab);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export class CardComponent {
|
|||||||
export class CardHeaderComponent {
|
export class CardHeaderComponent {
|
||||||
@Input() class = '';
|
@Input() class = '';
|
||||||
|
|
||||||
private baseClasses = 'p-6';
|
private baseClasses = 'block p-6';
|
||||||
|
|
||||||
get computedClasses(): string {
|
get computedClasses(): string {
|
||||||
return `${this.baseClasses} ${this.class}`.trim();
|
return `${this.baseClasses} ${this.class}`.trim();
|
||||||
@@ -44,6 +44,7 @@ export class CardHeaderComponent {
|
|||||||
host: {
|
host: {
|
||||||
'[class]': 'computedClasses',
|
'[class]': 'computedClasses',
|
||||||
},
|
},
|
||||||
|
styles: [':host { display: block; }'],
|
||||||
})
|
})
|
||||||
export class CardTitleComponent {
|
export class CardTitleComponent {
|
||||||
@Input() class = '';
|
@Input() class = '';
|
||||||
|
|||||||
Reference in New Issue
Block a user