4 Commits

Author SHA1 Message Date
c5d239ab28 v1.7.0 2025-11-27 13:48:11 +00:00
5cd7e7c252 feat(backup): Add backup system: BackupManager, DB schema, API endpoints and UI support
Introduce a complete service backup/restore subsystem with encrypted archives, database records and REST endpoints. Implements BackupManager with export/import for service config, platform resources (MongoDB, MinIO, ClickHouse), and Docker images; adds BackupRepository and migrations for backups table and include_image_in_backup; integrates backup flows into the HTTP API and the UI client; exposes backup password management and restore modes (restore/import/clone). Wire BackupManager into Onebox initialization.
2025-11-27 13:48:11 +00:00
e7ade45097 v1.6.0 2025-11-27 09:50:06 +00:00
7b159a3486 feat(ui.dashboard): Add Resource Usage card to dashboard and make dashboard cards full-height; add VSCode launch/tasks/config 2025-11-27 09:50:06 +00:00
20 changed files with 2308 additions and 10 deletions

View File

@@ -1,5 +1,25 @@
# Changelog
## 2025-11-27 - 1.7.0 - feat(backup)
Add backup system: BackupManager, DB schema, API endpoints and UI support
Introduce a complete service backup/restore subsystem with encrypted archives, database records and REST endpoints. Implements BackupManager with export/import for service config, platform resources (MongoDB, MinIO, ClickHouse), and Docker images; adds BackupRepository and migrations for backups table and include_image_in_backup; integrates backup flows into the HTTP API and the UI client; exposes backup password management and restore modes (restore/import/clone). Wire BackupManager into Onebox initialization.
- Add BackupManager implementing create/restore/export/import/encrypt/decrypt workflows (service config, platform resource dumps, Docker image export/import) and support for restore modes: restore, import, clone.
- Add BackupRepository and database migrations: create backups table and add include_image_in_backup column to services; database API methods for create/get/list/delete backups.
- Add HTTP API endpoints for backup management: list/create/get/download/delete backups, restore backups (/api/backups/restore) and backup password endpoints (/api/settings/backup-password).
- Update UI ApiService and types: add IBackup, IRestoreOptions, IRestoreResult, IBackupPasswordStatus and corresponding ApiService methods (getBackups, createBackup, getBackup, deleteBackup, getBackupDownloadUrl, restoreBackup, setBackupPassword, checkBackupPassword).
- Expose includeImageInBackup flag on service model and persist it in ServiceRepository (defaults to true for existing rows); service update flow supports toggling this option.
- Integrate BackupManager into Onebox core (initialized in Onebox constructor) and wire HTTP handlers to use the new manager; add DB repository export/import glue so backups are stored and referenced by ID.
## 2025-11-27 - 1.6.0 - feat(ui.dashboard)
Add Resource Usage card to dashboard and make dashboard cards full-height; add VSCode launch/tasks/config
- Introduce ResourceUsageCardComponent and include it as a full-width row in the dashboard layout.
- Make several dashboard card components (Certificates, Traffic, Platform Services) full-height by adding host classes and applying h-full to ui-card elements for consistent card sizing.
- Reflow dashboard rows (insert Resource Usage as a dedicated row and update row numbering) to improve visual layout.
- Add VSCode workspace configuration: recommended Angular extension, launch configurations for ng serve/ng test, and npm tasks to run/start the UI in development.
## 2025-11-27 - 1.5.0 - feat(network)
Add traffic stats endpoint and dashboard UI; enhance platform services and certificate health reporting

View File

@@ -1,6 +1,6 @@
{
"name": "@serve.zone/onebox",
"version": "1.5.0",
"version": "1.7.0",
"exports": "./mod.ts",
"nodeModulesDir": "auto",
"tasks": {

View File

@@ -1,6 +1,6 @@
{
"name": "@serve.zone/onebox",
"version": "1.5.0",
"version": "1.7.0",
"description": "Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers",
"main": "mod.ts",
"type": "module",

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/onebox',
version: '1.5.0',
version: '1.7.0',
description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers'
}

1112
ts/classes/backup-manager.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -319,6 +319,30 @@ export class OneboxHttpServer {
return await this.handleGetNetworkStatsRequest();
} else if (path === '/api/network/traffic-stats' && method === 'GET') {
return await this.handleGetTrafficStatsRequest(new URL(req.url));
// Backup endpoints
} else if (path === '/api/backups' && method === 'GET') {
return await this.handleListBackupsRequest();
} else if (path.match(/^\/api\/services\/[^/]+\/backups$/) && method === 'GET') {
const serviceName = path.split('/')[3];
return await this.handleListServiceBackupsRequest(serviceName);
} else if (path.match(/^\/api\/services\/[^/]+\/backup$/) && method === 'POST') {
const serviceName = path.split('/')[3];
return await this.handleCreateBackupRequest(serviceName);
} else if (path.match(/^\/api\/backups\/\d+$/) && method === 'GET') {
const backupId = Number(path.split('/').pop());
return await this.handleGetBackupRequest(backupId);
} else if (path.match(/^\/api\/backups\/\d+\/download$/) && method === 'GET') {
const backupId = Number(path.split('/')[3]);
return await this.handleDownloadBackupRequest(backupId);
} else if (path.match(/^\/api\/backups\/\d+$/) && method === 'DELETE') {
const backupId = Number(path.split('/').pop());
return await this.handleDeleteBackupRequest(backupId);
} else if (path === '/api/backups/restore' && method === 'POST') {
return await this.handleRestoreBackupRequest(req);
} else if (path === '/api/settings/backup-password' && method === 'POST') {
return await this.handleSetBackupPasswordRequest(req);
} else if (path === '/api/settings/backup-password' && method === 'GET') {
return await this.handleCheckBackupPasswordRequest();
} else {
return this.jsonResponse({ success: false, error: 'Not found' }, 404);
}
@@ -2017,6 +2041,276 @@ export class OneboxHttpServer {
}
}
// ============ Backup Endpoints ============
/**
* List all backups
*/
private async handleListBackupsRequest(): Promise<Response> {
try {
const backups = this.oneboxRef.backupManager.listBackups();
return this.jsonResponse({ success: true, data: backups });
} catch (error) {
logger.error(`Failed to list backups: ${getErrorMessage(error)}`);
return this.jsonResponse({
success: false,
error: getErrorMessage(error) || 'Failed to list backups',
}, 500);
}
}
/**
* List backups for a specific service
*/
private async handleListServiceBackupsRequest(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 backups = this.oneboxRef.backupManager.listBackups(serviceName);
return this.jsonResponse({ success: true, data: backups });
} catch (error) {
logger.error(`Failed to list backups for service ${serviceName}: ${getErrorMessage(error)}`);
return this.jsonResponse({
success: false,
error: getErrorMessage(error) || 'Failed to list backups',
}, 500);
}
}
/**
* Create a backup for a service
*/
private async handleCreateBackupRequest(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 result = await this.oneboxRef.backupManager.createBackup(serviceName);
return this.jsonResponse({
success: true,
message: `Backup created for service ${serviceName}`,
data: result.backup,
});
} catch (error) {
logger.error(`Failed to create backup for service ${serviceName}: ${getErrorMessage(error)}`);
return this.jsonResponse({
success: false,
error: getErrorMessage(error) || 'Failed to create backup',
}, 500);
}
}
/**
* Get a specific backup by ID
*/
private async handleGetBackupRequest(backupId: number): Promise<Response> {
try {
const backup = this.oneboxRef.database.getBackupById(backupId);
if (!backup) {
return this.jsonResponse({ success: false, error: 'Backup not found' }, 404);
}
return this.jsonResponse({ success: true, data: backup });
} catch (error) {
logger.error(`Failed to get backup ${backupId}: ${getErrorMessage(error)}`);
return this.jsonResponse({
success: false,
error: getErrorMessage(error) || 'Failed to get backup',
}, 500);
}
}
/**
* Download a backup file
*/
private async handleDownloadBackupRequest(backupId: number): Promise<Response> {
try {
const filePath = this.oneboxRef.backupManager.getBackupFilePath(backupId);
if (!filePath) {
return this.jsonResponse({ success: false, error: 'Backup not found' }, 404);
}
// Check if file exists
try {
await Deno.stat(filePath);
} catch {
return this.jsonResponse({ success: false, error: 'Backup file not found on disk' }, 404);
}
// Read file and return as download
const backup = this.oneboxRef.database.getBackupById(backupId);
const file = await Deno.readFile(filePath);
return new Response(file, {
status: 200,
headers: {
'Content-Type': 'application/octet-stream',
'Content-Disposition': `attachment; filename="${backup?.filename || 'backup.tar.enc'}"`,
'Content-Length': String(file.length),
},
});
} catch (error) {
logger.error(`Failed to download backup ${backupId}: ${getErrorMessage(error)}`);
return this.jsonResponse({
success: false,
error: getErrorMessage(error) || 'Failed to download backup',
}, 500);
}
}
/**
* Delete a backup
*/
private async handleDeleteBackupRequest(backupId: number): Promise<Response> {
try {
const backup = this.oneboxRef.database.getBackupById(backupId);
if (!backup) {
return this.jsonResponse({ success: false, error: 'Backup not found' }, 404);
}
await this.oneboxRef.backupManager.deleteBackup(backupId);
return this.jsonResponse({
success: true,
message: 'Backup deleted successfully',
});
} catch (error) {
logger.error(`Failed to delete backup ${backupId}: ${getErrorMessage(error)}`);
return this.jsonResponse({
success: false,
error: getErrorMessage(error) || 'Failed to delete backup',
}, 500);
}
}
/**
* Restore a backup
*/
private async handleRestoreBackupRequest(req: Request): Promise<Response> {
try {
const body = await req.json();
const { backupId, mode, newServiceName, overwriteExisting, skipPlatformData } = body;
if (!backupId) {
return this.jsonResponse({
success: false,
error: 'Backup ID is required',
}, 400);
}
if (!mode || !['restore', 'import', 'clone'].includes(mode)) {
return this.jsonResponse({
success: false,
error: 'Valid mode required: restore, import, or clone',
}, 400);
}
// Get backup file path
const filePath = this.oneboxRef.backupManager.getBackupFilePath(backupId);
if (!filePath) {
return this.jsonResponse({ success: false, error: 'Backup not found' }, 404);
}
// Validate mode-specific requirements
if ((mode === 'import' || mode === 'clone') && !newServiceName) {
return this.jsonResponse({
success: false,
error: `New service name required for '${mode}' mode`,
}, 400);
}
const result = await this.oneboxRef.backupManager.restoreBackup(filePath, {
mode,
newServiceName,
overwriteExisting: overwriteExisting === true,
skipPlatformData: skipPlatformData === true,
});
return this.jsonResponse({
success: true,
message: `Backup restored successfully as service '${result.service.name}'`,
data: {
service: result.service,
platformResourcesRestored: result.platformResourcesRestored,
warnings: result.warnings,
},
});
} catch (error) {
logger.error(`Failed to restore backup: ${getErrorMessage(error)}`);
return this.jsonResponse({
success: false,
error: getErrorMessage(error) || 'Failed to restore backup',
}, 500);
}
}
/**
* Set backup encryption password
*/
private async handleSetBackupPasswordRequest(req: Request): Promise<Response> {
try {
const body = await req.json();
const { password } = body;
if (!password || typeof password !== 'string') {
return this.jsonResponse({
success: false,
error: 'Password is required',
}, 400);
}
if (password.length < 8) {
return this.jsonResponse({
success: false,
error: 'Password must be at least 8 characters',
}, 400);
}
// Store password in settings
this.oneboxRef.database.setSetting('backup_encryption_password', password);
return this.jsonResponse({
success: true,
message: 'Backup password set successfully',
});
} catch (error) {
logger.error(`Failed to set backup password: ${getErrorMessage(error)}`);
return this.jsonResponse({
success: false,
error: getErrorMessage(error) || 'Failed to set backup password',
}, 500);
}
}
/**
* Check if backup password is configured
*/
private async handleCheckBackupPasswordRequest(): Promise<Response> {
try {
const password = this.oneboxRef.database.getSetting('backup_encryption_password');
const isConfigured = password !== null && password.length > 0;
return this.jsonResponse({
success: true,
data: {
isConfigured,
},
});
} catch (error) {
logger.error(`Failed to check backup password: ${getErrorMessage(error)}`);
return this.jsonResponse({
success: false,
error: getErrorMessage(error) || 'Failed to check backup password',
}, 500);
}
}
/**
* Helper to create JSON response
*/

View File

@@ -20,6 +20,7 @@ import { CertRequirementManager } from './cert-requirement-manager.ts';
import { RegistryManager } from './registry.ts';
import { PlatformServicesManager } from './platform-services/index.ts';
import { CaddyLogReceiver } from './caddy-log-receiver.ts';
import { BackupManager } from './backup-manager.ts';
export class Onebox {
public database: OneboxDatabase;
@@ -36,6 +37,7 @@ export class Onebox {
public registry: RegistryManager;
public platformServices: PlatformServicesManager;
public caddyLogReceiver: CaddyLogReceiver;
public backupManager: BackupManager;
private initialized = false;
@@ -67,6 +69,9 @@ export class Onebox {
// Initialize Caddy log receiver
this.caddyLogReceiver = new CaddyLogReceiver(9999);
// Initialize Backup manager
this.backupManager = new BackupManager(this);
}
/**

View File

@@ -18,6 +18,7 @@ import type {
IDomain,
ICertificate,
ICertRequirement,
IBackup,
} from '../types.ts';
import type { TBindValue } from './types.ts';
import { logger } from '../logging.ts';
@@ -31,6 +32,7 @@ import {
AuthRepository,
MetricsRepository,
PlatformRepository,
BackupRepository,
} from './repositories/index.ts';
export class OneboxDatabase {
@@ -44,6 +46,7 @@ export class OneboxDatabase {
private authRepo!: AuthRepository;
private metricsRepo!: MetricsRepository;
private platformRepo!: PlatformRepository;
private backupRepo!: BackupRepository;
constructor(dbPath = './.nogit/onebox.db') {
this.dbPath = dbPath;
@@ -76,6 +79,7 @@ export class OneboxDatabase {
this.authRepo = new AuthRepository(queryFn);
this.metricsRepo = new MetricsRepository(queryFn);
this.platformRepo = new PlatformRepository(queryFn);
this.backupRepo = new BackupRepository(queryFn);
} catch (error) {
logger.error(`Failed to initialize database: ${getErrorMessage(error)}`);
throw error;
@@ -705,6 +709,37 @@ export class OneboxDatabase {
this.setMigrationVersion(8);
logger.success('Migration 8 completed: Certificates table now stores PEM content');
}
// Migration 9: Backup system tables
const version9 = this.getMigrationVersion();
if (version9 < 9) {
logger.info('Running migration 9: Creating backup system tables...');
// Add include_image_in_backup column to services table
this.query(`ALTER TABLE services ADD COLUMN include_image_in_backup INTEGER DEFAULT 1`);
// Create backups table
this.query(`
CREATE TABLE backups (
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,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
)
`);
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.setMigrationVersion(9);
logger.success('Migration 9 completed: Backup system tables created');
}
} catch (error) {
logger.error(`Migration failed: ${getErrorMessage(error)}`);
if (error instanceof Error && error.stack) {
@@ -1078,4 +1113,30 @@ export class OneboxDatabase {
deletePlatformResourcesByService(serviceId: number): void {
this.platformRepo.deletePlatformResourcesByService(serviceId);
}
// ============ Backups (delegated to repository) ============
createBackup(backup: Omit<IBackup, 'id'>): IBackup {
return this.backupRepo.create(backup);
}
getBackupById(id: number): IBackup | null {
return this.backupRepo.getById(id);
}
getBackupsByService(serviceId: number): IBackup[] {
return this.backupRepo.getByService(serviceId);
}
getAllBackups(): IBackup[] {
return this.backupRepo.getAll();
}
deleteBackup(id: number): void {
this.backupRepo.delete(id);
}
deleteBackupsByService(serviceId: number): void {
this.backupRepo.deleteByService(serviceId);
}
}

View File

@@ -0,0 +1,86 @@
/**
* Backup Repository
* Handles CRUD operations for backups table
*/
import { BaseRepository } from '../base.repository.ts';
import type { IBackup, TPlatformServiceType } from '../../types.ts';
export class BackupRepository extends BaseRepository {
create(backup: Omit<IBackup, 'id'>): IBackup {
this.query(
`INSERT INTO backups (
service_id, service_name, filename, size_bytes, created_at,
includes_image, platform_resources, checksum
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
backup.serviceId,
backup.serviceName,
backup.filename,
backup.sizeBytes,
backup.createdAt,
backup.includesImage ? 1 : 0,
JSON.stringify(backup.platformResources),
backup.checksum,
]
);
// Get the created backup by looking for the most recent one with matching filename
const rows = this.query(
'SELECT * FROM backups WHERE filename = ? ORDER BY id DESC LIMIT 1',
[backup.filename]
);
return this.rowToBackup(rows[0]);
}
getById(id: number): IBackup | null {
const rows = this.query('SELECT * FROM backups WHERE id = ?', [id]);
return rows.length > 0 ? this.rowToBackup(rows[0]) : null;
}
getByService(serviceId: number): IBackup[] {
const rows = this.query(
'SELECT * FROM backups WHERE service_id = ? ORDER BY created_at DESC',
[serviceId]
);
return rows.map((row) => this.rowToBackup(row));
}
getAll(): IBackup[] {
const rows = this.query('SELECT * FROM backups ORDER BY created_at DESC');
return rows.map((row) => this.rowToBackup(row));
}
delete(id: number): void {
this.query('DELETE FROM backups WHERE id = ?', [id]);
}
deleteByService(serviceId: number): void {
this.query('DELETE FROM backups WHERE service_id = ?', [serviceId]);
}
private rowToBackup(row: any): IBackup {
let platformResources: TPlatformServiceType[] = [];
const platformResourcesRaw = row.platform_resources;
if (platformResourcesRaw) {
try {
platformResources = JSON.parse(String(platformResourcesRaw));
} catch {
platformResources = [];
}
}
return {
id: Number(row.id),
serviceId: Number(row.service_id),
serviceName: String(row.service_name),
filename: String(row.filename),
sizeBytes: Number(row.size_bytes),
createdAt: Number(row.created_at),
includesImage: Boolean(row.includes_image),
platformResources,
checksum: String(row.checksum),
};
}
}

View File

@@ -8,3 +8,4 @@ export { CertificateRepository } from './certificate.repository.ts';
export { AuthRepository } from './auth.repository.ts';
export { MetricsRepository } from './metrics.repository.ts';
export { PlatformRepository } from './platform.repository.ts';
export { BackupRepository } from './backup.repository.ts';

View File

@@ -119,6 +119,10 @@ export class ServiceRepository extends BaseRepository {
fields.push('platform_requirements = ?');
values.push(JSON.stringify(updates.platformRequirements));
}
if (updates.includeImageInBackup !== undefined) {
fields.push('include_image_in_backup = ?');
values.push(updates.includeImageInBackup ? 1 : 0);
}
fields.push('updated_at = ?');
values.push(Date.now());
@@ -172,6 +176,9 @@ export class ServiceRepository extends BaseRepository {
autoUpdateOnPush: row.auto_update_on_push ? Boolean(row.auto_update_on_push) : undefined,
imageDigest: row.image_digest ? String(row.image_digest) : undefined,
platformRequirements,
includeImageInBackup: row.include_image_in_backup !== undefined
? Boolean(row.include_image_in_backup)
: true, // Default to true
};
}
}

View File

@@ -23,6 +23,8 @@ export interface IService {
imageDigest?: string;
// Platform service requirements
platformRequirements?: IPlatformRequirements;
// Backup settings
includeImageInBackup?: boolean;
}
// Registry types
@@ -317,3 +319,68 @@ export interface ICliArgs {
_: string[];
[key: string]: unknown;
}
// Backup types
export type TBackupRestoreMode = 'restore' | 'import' | 'clone';
export interface IBackup {
id?: number;
serviceId: number;
serviceName: string; // Denormalized for display
filename: string;
sizeBytes: number;
createdAt: number;
includesImage: boolean;
platformResources: TPlatformServiceType[]; // Which platform types were backed up
checksum: string;
}
export interface IBackupManifest {
version: string;
createdAt: number;
oneboxVersion: string;
serviceName: string;
includesImage: boolean;
platformResources: TPlatformServiceType[];
checksum: string;
}
export interface IBackupServiceConfig {
name: string;
image: string;
registry?: string;
envVars: Record<string, string>;
port: number;
domain?: string;
useOneboxRegistry?: boolean;
registryRepository?: string;
registryImageTag?: string;
autoUpdateOnPush?: boolean;
platformRequirements?: IPlatformRequirements;
includeImageInBackup?: boolean;
}
export interface IBackupPlatformResource {
resourceType: TPlatformResourceType;
resourceName: string;
platformServiceType: TPlatformServiceType;
credentials: Record<string, string>; // Decrypted for backup, re-encrypted on restore
}
export interface IBackupResult {
backup: IBackup;
filePath: string;
}
export interface IRestoreOptions {
mode: TBackupRestoreMode;
newServiceName?: string; // Required for 'import' and 'clone' modes
skipPlatformData?: boolean; // Restore config only, skip DB/bucket data
overwriteExisting?: boolean; // For 'restore' mode
}
export interface IRestoreResult {
service: IService;
platformResourcesRestored: number;
warnings: string[];
}

View File

@@ -25,6 +25,10 @@ import {
IContainerStats,
IMetric,
ITrafficStats,
IBackup,
IRestoreOptions,
IRestoreResult,
IBackupPasswordStatus,
} from '../types/api.types';
@Injectable({ providedIn: 'root' })
@@ -210,4 +214,50 @@ export class ApiService {
const params = minutes ? `?minutes=${minutes}` : '';
return firstValueFrom(this.http.get<IApiResponse<ITrafficStats>>(`/api/network/traffic-stats${params}`));
}
// Backups
async getBackups(): Promise<IApiResponse<IBackup[]>> {
return firstValueFrom(this.http.get<IApiResponse<IBackup[]>>('/api/backups'));
}
async getServiceBackups(serviceName: string): Promise<IApiResponse<IBackup[]>> {
return firstValueFrom(this.http.get<IApiResponse<IBackup[]>>(`/api/services/${serviceName}/backups`));
}
async createBackup(serviceName: string): Promise<IApiResponse<IBackup>> {
return firstValueFrom(this.http.post<IApiResponse<IBackup>>(`/api/services/${serviceName}/backup`, {}));
}
async getBackup(backupId: number): Promise<IApiResponse<IBackup>> {
return firstValueFrom(this.http.get<IApiResponse<IBackup>>(`/api/backups/${backupId}`));
}
async deleteBackup(backupId: number): Promise<IApiResponse<void>> {
return firstValueFrom(this.http.delete<IApiResponse<void>>(`/api/backups/${backupId}`));
}
getBackupDownloadUrl(backupId: number): string {
return `/api/backups/${backupId}/download`;
}
async restoreBackup(backupId: number, options: IRestoreOptions): Promise<IApiResponse<IRestoreResult>> {
return firstValueFrom(
this.http.post<IApiResponse<IRestoreResult>>('/api/backups/restore', {
backupId,
...options,
})
);
}
async setBackupPassword(password: string): Promise<IApiResponse<void>> {
return firstValueFrom(
this.http.post<IApiResponse<void>>('/api/settings/backup-password', { password })
);
}
async checkBackupPassword(): Promise<IApiResponse<IBackupPasswordStatus>> {
return firstValueFrom(
this.http.get<IApiResponse<IBackupPasswordStatus>>('/api/settings/backup-password')
);
}
}

View File

@@ -344,3 +344,35 @@ export interface ITrafficStats {
requestsPerMinute: number;
errorRate: number; // percentage
}
// Backup Types
export interface IBackup {
id?: number;
serviceId: number;
serviceName: string;
filename: string;
sizeBytes: number;
createdAt: number;
includesImage: boolean;
platformResources: TPlatformServiceType[];
checksum: string;
}
export type TRestoreMode = 'restore' | 'import' | 'clone';
export interface IRestoreOptions {
mode: TRestoreMode;
newServiceName?: string;
overwriteExisting?: boolean;
skipPlatformData?: boolean;
}
export interface IRestoreResult {
service: IService;
platformResourcesRestored: number;
warnings: string[];
}
export interface IBackupPasswordStatus {
isConfigured: boolean;
}

View File

@@ -18,6 +18,7 @@ interface ICertificateHealth {
@Component({
selector: 'app-certificates-card',
standalone: true,
host: { class: 'block h-full' },
imports: [
RouterLink,
CardComponent,
@@ -27,7 +28,7 @@ interface ICertificateHealth {
CardContentComponent,
],
template: `
<ui-card>
<ui-card class="h-full">
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Certificates</ui-card-title>
<ui-card-description>SSL/TLS certificate status</ui-card-description>

View File

@@ -17,6 +17,7 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
import { TrafficCardComponent } from './traffic-card.component';
import { PlatformServicesCardComponent } from './platform-services-card.component';
import { CertificatesCardComponent } from './certificates-card.component';
import { ResourceUsageCardComponent } from './resource-usage-card.component';
@Component({
selector: 'app-dashboard',
@@ -34,6 +35,7 @@ import { CertificatesCardComponent } from './certificates-card.component';
TrafficCardComponent,
PlatformServicesCardComponent,
CertificatesCardComponent,
ResourceUsageCardComponent,
],
template: `
<div class="space-y-6">
@@ -123,7 +125,10 @@ import { CertificatesCardComponent } from './certificates-card.component';
</ui-card>
</div>
<!-- Row 2: Traffic & Platform Services (2-column) -->
<!-- Row 2: Resource Usage (full width) -->
<app-resource-usage-card />
<!-- Row 3: Traffic & Platform Services (2-column) -->
<div class="grid gap-4 md:grid-cols-2">
<!-- Traffic Overview -->
<app-traffic-card />
@@ -132,7 +137,7 @@ import { CertificatesCardComponent } from './certificates-card.component';
<app-platform-services-card [services]="status()!.platformServices" />
</div>
<!-- Row 3: Certificates & System Status (3-column) -->
<!-- Row 4: Certificates & System Status (3-column) -->
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<!-- Certificates Health -->
<app-certificates-card [health]="status()!.certificateHealth" />
@@ -186,7 +191,7 @@ import { CertificatesCardComponent } from './certificates-card.component';
</ui-card>
</div>
<!-- Row 4: Quick Actions -->
<!-- Row 5: Quick Actions -->
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Quick Actions</ui-card-title>

View File

@@ -19,6 +19,7 @@ interface IPlatformServiceSummary {
@Component({
selector: 'app-platform-services-card',
standalone: true,
host: { class: 'block h-full' },
imports: [
RouterLink,
CardComponent,
@@ -28,7 +29,7 @@ interface IPlatformServiceSummary {
CardContentComponent,
],
template: `
<ui-card>
<ui-card class="h-full">
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Platform Services</ui-card-title>
<ui-card-description>Infrastructure status</ui-card-description>

View File

@@ -0,0 +1,271 @@
import { Component, inject, signal, effect, OnDestroy } from '@angular/core';
import { RouterLink } from '@angular/router';
import { WebSocketService } from '../../core/services/websocket.service';
import { IContainerStats } from '../../core/types/api.types';
import {
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
} from '../../ui/card/card.component';
interface IServiceStats {
name: string;
stats: IContainerStats;
timestamp: number;
}
interface IAggregatedStats {
totalCpuPercent: number;
totalMemoryUsed: number;
totalMemoryLimit: number;
memoryPercent: number;
networkRxRate: number;
networkTxRate: number;
serviceCount: number;
topCpuServices: { name: string; value: number }[];
topMemoryServices: { name: string; value: number }[];
}
@Component({
selector: 'app-resource-usage-card',
standalone: true,
host: { class: 'block' },
imports: [
RouterLink,
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
],
template: `
<ui-card>
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
<div>
<ui-card-title>Resource Usage</ui-card-title>
<ui-card-description>Aggregated across {{ aggregated().serviceCount }} services</ui-card-description>
</div>
<a routerLink="/services" class="text-xs text-muted-foreground hover:text-primary transition-colors">
View All
</a>
</ui-card-header>
<ui-card-content class="space-y-4">
@if (aggregated().serviceCount === 0) {
<div class="text-sm text-muted-foreground">No running services</div>
} @else {
<!-- CPU Usage -->
<div class="space-y-1">
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">CPU</span>
<span class="font-medium" [class.text-warning]="aggregated().totalCpuPercent > 70" [class.text-destructive]="aggregated().totalCpuPercent > 90">
{{ aggregated().totalCpuPercent.toFixed(1) }}%
</span>
</div>
<div class="h-2 rounded-full bg-muted overflow-hidden">
<div
class="h-full transition-all duration-300"
[class.bg-success]="aggregated().totalCpuPercent <= 70"
[class.bg-warning]="aggregated().totalCpuPercent > 70 && aggregated().totalCpuPercent <= 90"
[class.bg-destructive]="aggregated().totalCpuPercent > 90"
[style.width.%]="Math.min(aggregated().totalCpuPercent, 100)">
</div>
</div>
</div>
<!-- Memory Usage -->
<div class="space-y-1">
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">Memory</span>
<span class="font-medium" [class.text-warning]="aggregated().memoryPercent > 70" [class.text-destructive]="aggregated().memoryPercent > 90">
{{ formatBytes(aggregated().totalMemoryUsed) }} / {{ formatBytes(aggregated().totalMemoryLimit) }}
</span>
</div>
<div class="h-2 rounded-full bg-muted overflow-hidden">
<div
class="h-full transition-all duration-300"
[class.bg-success]="aggregated().memoryPercent <= 70"
[class.bg-warning]="aggregated().memoryPercent > 70 && aggregated().memoryPercent <= 90"
[class.bg-destructive]="aggregated().memoryPercent > 90"
[style.width.%]="Math.min(aggregated().memoryPercent, 100)">
</div>
</div>
</div>
<!-- Network -->
<div class="flex items-center justify-between text-sm pt-1 border-t">
<span class="text-muted-foreground">Network</span>
<div class="flex items-center gap-3">
<span class="flex items-center gap-1">
<svg class="h-3 w-3 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
{{ formatBytesRate(aggregated().networkRxRate) }}
</span>
<span class="flex items-center gap-1">
<svg class="h-3 w-3 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 10l7-7m0 0l7 7m-7-7v18" />
</svg>
{{ formatBytesRate(aggregated().networkTxRate) }}
</span>
</div>
</div>
<!-- Top Consumers -->
@if (aggregated().topCpuServices.length > 0 || aggregated().topMemoryServices.length > 0) {
<div class="pt-2 border-t">
<div class="text-xs text-muted-foreground mb-1">Top consumers</div>
<div class="flex flex-wrap gap-x-4 gap-y-1 text-xs">
@for (svc of aggregated().topCpuServices.slice(0, 2); track svc.name) {
<span>
<span class="text-muted-foreground">{{ svc.name }}:</span>
<span class="font-medium"> {{ svc.value.toFixed(1) }}% CPU</span>
</span>
}
@for (svc of aggregated().topMemoryServices.slice(0, 2); track svc.name) {
<span>
<span class="text-muted-foreground">{{ svc.name }}:</span>
<span class="font-medium"> {{ formatBytes(svc.value) }}</span>
</span>
}
</div>
</div>
}
}
</ui-card-content>
</ui-card>
`,
})
export class ResourceUsageCardComponent implements OnDestroy {
private ws = inject(WebSocketService);
// Store stats per service
private serviceStats = new Map<string, IServiceStats>();
private cleanupInterval: any;
// Expose Math for template
Math = Math;
aggregated = signal<IAggregatedStats>({
totalCpuPercent: 0,
totalMemoryUsed: 0,
totalMemoryLimit: 0,
memoryPercent: 0,
networkRxRate: 0,
networkTxRate: 0,
serviceCount: 0,
topCpuServices: [],
topMemoryServices: [],
});
constructor() {
// Listen for stats updates
effect(() => {
const update = this.ws.statsUpdate();
if (update) {
this.serviceStats.set(update.serviceName, {
name: update.serviceName,
stats: update.stats,
timestamp: update.timestamp,
});
this.recalculateAggregated();
}
});
// Clean up stale entries every 30 seconds
this.cleanupInterval = setInterval(() => {
const now = Date.now();
const staleThreshold = 60000; // 60 seconds
let changed = false;
for (const [name, entry] of this.serviceStats.entries()) {
if (now - entry.timestamp > staleThreshold) {
this.serviceStats.delete(name);
changed = true;
}
}
if (changed) {
this.recalculateAggregated();
}
}, 30000);
}
ngOnDestroy(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
}
}
private recalculateAggregated(): void {
const entries = Array.from(this.serviceStats.values());
if (entries.length === 0) {
this.aggregated.set({
totalCpuPercent: 0,
totalMemoryUsed: 0,
totalMemoryLimit: 0,
memoryPercent: 0,
networkRxRate: 0,
networkTxRate: 0,
serviceCount: 0,
topCpuServices: [],
topMemoryServices: [],
});
return;
}
let totalCpu = 0;
let totalMemUsed = 0;
let totalMemLimit = 0;
let totalNetRx = 0;
let totalNetTx = 0;
for (const entry of entries) {
totalCpu += entry.stats.cpuPercent;
totalMemUsed += entry.stats.memoryUsed;
totalMemLimit += entry.stats.memoryLimit;
totalNetRx += entry.stats.networkRx;
totalNetTx += entry.stats.networkTx;
}
// Sort by CPU usage for top consumers
const sortedByCpu = [...entries]
.filter(e => e.stats.cpuPercent > 0)
.sort((a, b) => b.stats.cpuPercent - a.stats.cpuPercent)
.slice(0, 3)
.map(e => ({ name: e.name, value: e.stats.cpuPercent }));
// Sort by memory usage for top consumers
const sortedByMem = [...entries]
.filter(e => e.stats.memoryUsed > 0)
.sort((a, b) => b.stats.memoryUsed - a.stats.memoryUsed)
.slice(0, 3)
.map(e => ({ name: e.name, value: e.stats.memoryUsed }));
this.aggregated.set({
totalCpuPercent: totalCpu,
totalMemoryUsed: totalMemUsed,
totalMemoryLimit: totalMemLimit,
memoryPercent: totalMemLimit > 0 ? (totalMemUsed / totalMemLimit) * 100 : 0,
networkRxRate: totalNetRx,
networkTxRate: totalNetTx,
serviceCount: entries.length,
topCpuServices: sortedByCpu,
topMemoryServices: sortedByMem,
});
}
formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
formatBytesRate(bytes: number): string {
return this.formatBytes(bytes) + '/s';
}
}

View File

@@ -13,6 +13,7 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
@Component({
selector: 'app-traffic-card',
standalone: true,
host: { class: 'block h-full' },
imports: [
CardComponent,
CardHeaderComponent,
@@ -22,7 +23,7 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
SkeletonComponent,
],
template: `
<ui-card>
<ui-card class="h-full">
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Traffic (Last Hour)</ui-card-title>
<ui-card-description>Request metrics from access logs</ui-card-description>

View File

@@ -6,7 +6,7 @@ import { ApiService } from '../../core/services/api.service';
import { ToastService } from '../../core/services/toast.service';
import { LogStreamService } from '../../core/services/log-stream.service';
import { WebSocketService } from '../../core/services/websocket.service';
import { IService, IServiceUpdate, IPlatformResource, IContainerStats, IMetric } from '../../core/types/api.types';
import { IService, IServiceUpdate, IPlatformResource, IContainerStats, IMetric, IBackup, TRestoreMode } from '../../core/types/api.types';
import { ContainerStatsComponent } from '../../shared/components/container-stats/container-stats.component';
import {
CardComponent,
@@ -333,6 +333,79 @@ import {
</ui-card-content>
</ui-card>
}
<!-- Backups -->
<ui-card>
<ui-card-header class="flex flex-row items-center justify-between">
<div class="flex flex-col space-y-1.5">
<ui-card-title>Backups</ui-card-title>
<ui-card-description>Create and manage service backups</ui-card-description>
</div>
<button uiButton size="sm" (click)="createBackup()" [disabled]="backupLoading()">
@if (backupLoading()) {
<svg class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Creating...
} @else {
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
Create Backup
}
</button>
</ui-card-header>
<ui-card-content>
@if (backups().length > 0) {
<div class="space-y-3">
@for (backup of backups(); track backup.id) {
<div class="border rounded-lg p-3 flex items-center justify-between">
<div class="space-y-1">
<div class="text-sm font-medium">{{ formatDate(backup.createdAt) }}</div>
<div class="text-xs text-muted-foreground flex items-center gap-2">
<span>{{ formatBytes(backup.sizeBytes) }}</span>
@if (backup.includesImage) {
<ui-badge variant="outline" class="text-xs">Docker Image</ui-badge>
}
@for (res of backup.platformResources; track res) {
<ui-badge variant="outline" class="text-xs">{{ res }}</ui-badge>
}
</div>
</div>
<div class="flex items-center gap-2">
<a [href]="getBackupDownloadUrl(backup.id!)" class="inline-flex" download>
<button uiButton variant="ghost" size="sm" title="Download">
<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>
</button>
</a>
<button uiButton variant="ghost" size="sm" (click)="openRestoreDialog(backup)" title="Restore">
<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 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<button uiButton variant="ghost" size="sm" (click)="openDeleteBackupDialog(backup)" title="Delete">
<svg class="h-4 w-4 text-destructive" 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>
</div>
}
</div>
} @else {
<div class="text-center py-6 text-muted-foreground">
<svg class="h-12 w-12 mx-auto mb-3 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
</svg>
<p class="text-sm">No backups yet</p>
<p class="text-xs mt-1">Create a backup to protect your service data</p>
</div>
}
</ui-card-content>
</ui-card>
</div>
<!-- Logs Section -->
@@ -420,6 +493,85 @@ import {
</button>
</ui-dialog-footer>
</ui-dialog>
<!-- Delete Backup 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 from {{ formatDate(selectedBackup()?.createdAt || 0) }}? 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]="backupLoading()">
Delete Backup
</button>
</ui-dialog-footer>
</ui-dialog>
<!-- Restore Dialog -->
<ui-dialog [open]="restoreDialogOpen()" (openChange)="restoreDialogOpen.set($event)">
<ui-dialog-header>
<ui-dialog-title>Restore Backup</ui-dialog-title>
<ui-dialog-description>
Choose how to restore this backup from {{ formatDate(selectedBackup()?.createdAt || 0) }}.
</ui-dialog-description>
</ui-dialog-header>
<div class="space-y-4 py-4">
<div class="space-y-2">
<label uiLabel>Restore Mode</label>
<div class="space-y-2">
<label class="flex items-center gap-2 p-3 border rounded-lg cursor-pointer hover:bg-muted/50" [class.border-primary]="restoreMode() === 'restore'">
<input type="radio" name="restoreMode" value="restore" [checked]="restoreMode() === 'restore'" (change)="restoreMode.set('restore')" class="h-4 w-4" />
<div>
<div class="font-medium text-sm">Restore to existing service</div>
<div class="text-xs text-muted-foreground">Overwrite current service with backup data</div>
</div>
</label>
<label class="flex items-center gap-2 p-3 border rounded-lg cursor-pointer hover:bg-muted/50" [class.border-primary]="restoreMode() === 'clone'">
<input type="radio" name="restoreMode" value="clone" [checked]="restoreMode() === 'clone'" (change)="restoreMode.set('clone')" class="h-4 w-4" />
<div>
<div class="font-medium text-sm">Clone as new service</div>
<div class="text-xs text-muted-foreground">Create a copy of the service with backup data</div>
</div>
</label>
</div>
</div>
@if (restoreMode() === 'clone') {
<div class="space-y-2">
<label uiLabel>New Service Name</label>
<input uiInput [(ngModel)]="restoreNewServiceName" placeholder="e.g. my-service-clone" />
</div>
}
@if (restoreMode() === 'restore') {
<div class="bg-amber-500/10 border border-amber-500/20 rounded-lg p-3">
<div class="flex gap-2 items-start">
<svg class="h-5 w-5 text-amber-500 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div class="text-sm text-amber-500">
<strong>Warning:</strong> This will overwrite the current service configuration and data.
</div>
</div>
</div>
}
</div>
<ui-dialog-footer>
<button uiButton variant="outline" (click)="restoreDialogOpen.set(false)">Cancel</button>
<button uiButton (click)="restoreBackup()" [disabled]="backupLoading() || (restoreMode() === 'clone' && !restoreNewServiceName)">
@if (backupLoading()) {
<svg class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Restoring...
} @else {
Restore Backup
}
</button>
</ui-dialog-footer>
</ui-dialog>
`,
})
export class ServiceDetailComponent implements OnInit, OnDestroy {
@@ -437,11 +589,18 @@ export class ServiceDetailComponent implements OnInit, OnDestroy {
platformResources = signal<IPlatformResource[]>([]);
stats = signal<IContainerStats | null>(null);
metrics = signal<IMetric[]>([]);
backups = signal<IBackup[]>([]);
loading = signal(false);
actionLoading = signal(false);
backupLoading = signal(false);
editMode = signal(false);
deleteDialogOpen = signal(false);
deleteBackupDialogOpen = signal(false);
restoreDialogOpen = signal(false);
selectedBackup = signal<IBackup | null>(null);
restoreMode = signal<TRestoreMode>('restore');
autoScroll = true;
restoreNewServiceName = '';
editForm: IServiceUpdate = {};
@@ -506,6 +665,9 @@ export class ServiceDetailComponent implements OnInit, OnDestroy {
this.loadPlatformResources(name);
}
// Load backups for this service
this.loadBackups(name);
// Load initial stats and metrics if service is running
// (WebSocket will keep stats updated in real-time)
if (response.data.status === 'running') {
@@ -737,4 +899,126 @@ export class ServiceDetailComponent implements OnInit, OnDestroy {
this.deleteDialogOpen.set(false);
}
}
// Backup methods
async loadBackups(name: string): Promise<void> {
try {
const response = await this.api.getServiceBackups(name);
if (response.success && response.data) {
this.backups.set(response.data);
}
} catch {
// Silent fail - backups are optional
}
}
async createBackup(): Promise<void> {
const name = this.service()?.name;
if (!name) return;
this.backupLoading.set(true);
try {
const response = await this.api.createBackup(name);
if (response.success) {
this.toast.success('Backup created successfully');
this.loadBackups(name);
} else {
this.toast.error(response.error || 'Failed to create backup');
}
} catch {
this.toast.error('Failed to create backup');
} finally {
this.backupLoading.set(false);
}
}
openDeleteBackupDialog(backup: IBackup): void {
this.selectedBackup.set(backup);
this.deleteBackupDialogOpen.set(true);
}
async deleteBackup(): Promise<void> {
const backup = this.selectedBackup();
const serviceName = this.service()?.name;
if (!backup?.id || !serviceName) return;
this.backupLoading.set(true);
try {
const response = await this.api.deleteBackup(backup.id);
if (response.success) {
this.toast.success('Backup deleted');
this.loadBackups(serviceName);
} else {
this.toast.error(response.error || 'Failed to delete backup');
}
} catch {
this.toast.error('Failed to delete backup');
} finally {
this.backupLoading.set(false);
this.deleteBackupDialogOpen.set(false);
this.selectedBackup.set(null);
}
}
openRestoreDialog(backup: IBackup): void {
this.selectedBackup.set(backup);
this.restoreMode.set('restore');
this.restoreNewServiceName = '';
this.restoreDialogOpen.set(true);
}
async restoreBackup(): Promise<void> {
const backup = this.selectedBackup();
const serviceName = this.service()?.name;
if (!backup?.id || !serviceName) return;
const mode = this.restoreMode();
if (mode === 'clone' && !this.restoreNewServiceName.trim()) {
this.toast.error('Please enter a new service name');
return;
}
this.backupLoading.set(true);
try {
const response = await this.api.restoreBackup(backup.id, {
mode,
newServiceName: mode === 'clone' ? this.restoreNewServiceName.trim() : undefined,
overwriteExisting: mode === 'restore',
});
if (response.success && response.data) {
if (response.data.warnings && response.data.warnings.length > 0) {
this.toast.warning(`Restored with warnings: ${response.data.warnings.join(', ')}`);
} else {
this.toast.success('Backup restored successfully');
}
if (mode === 'clone') {
this.router.navigate(['/services', response.data.service.name]);
} else {
this.loadService(serviceName);
}
} else {
this.toast.error(response.error || 'Failed to restore backup');
}
} catch {
this.toast.error('Failed to restore backup');
} finally {
this.backupLoading.set(false);
this.restoreDialogOpen.set(false);
this.selectedBackup.set(null);
}
}
getBackupDownloadUrl(backupId: number): string {
return this.api.getBackupDownloadUrl(backupId);
}
formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
}