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.
This commit is contained in:
2025-11-27 13:48:11 +00:00
parent e7ade45097
commit 5cd7e7c252
13 changed files with 2013 additions and 2 deletions

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

@@ -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];
}
}