Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 34c90e21db | |||
| ea7bb1395f |
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2025-11-27 - 1.9.0 - feat(backups)
|
||||||
|
Add backup import API and improve backup download/import flow in UI
|
||||||
|
|
||||||
|
- Backend: add /api/backups/import endpoint to accept multipart file uploads or JSON with a URL and import backups (saves temp file, validates .tar.enc, calls backupManager.restoreBackup in import mode).
|
||||||
|
- Backend: server-side import handler downloads remote backup URLs, stores temporary file, invokes restore/import logic and cleans up temp files.
|
||||||
|
- Frontend: add downloadBackup, importBackupFromFile and importBackupFromUrl methods to ApiService; trigger browser download using Blob and object URL with Authorization header.
|
||||||
|
- Frontend: replace raw download link in service detail UI with a Download button that calls downloadBackup and shows success/error toasts.
|
||||||
|
- Dev: add VS Code launch, tasks and recommended extensions for the ui workspace to simplify local development.
|
||||||
|
|
||||||
## 2025-11-27 - 1.8.0 - feat(backup)
|
## 2025-11-27 - 1.8.0 - feat(backup)
|
||||||
Add backup scheduling system with GFS retention, API and UI integration
|
Add backup scheduling system with GFS retention, API and UI integration
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/onebox",
|
"name": "@serve.zone/onebox",
|
||||||
"version": "1.8.0",
|
"version": "1.9.0",
|
||||||
"exports": "./mod.ts",
|
"exports": "./mod.ts",
|
||||||
"nodeModulesDir": "auto",
|
"nodeModulesDir": "auto",
|
||||||
"tasks": {
|
"tasks": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/onebox",
|
"name": "@serve.zone/onebox",
|
||||||
"version": "1.8.0",
|
"version": "1.9.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",
|
||||||
"main": "mod.ts",
|
"main": "mod.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/onebox',
|
name: '@serve.zone/onebox',
|
||||||
version: '1.8.0',
|
version: '1.9.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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -347,6 +347,8 @@ export class OneboxHttpServer {
|
|||||||
return await this.handleDeleteBackupRequest(backupId);
|
return await this.handleDeleteBackupRequest(backupId);
|
||||||
} else if (path === '/api/backups/restore' && method === 'POST') {
|
} else if (path === '/api/backups/restore' && method === 'POST') {
|
||||||
return await this.handleRestoreBackupRequest(req);
|
return await this.handleRestoreBackupRequest(req);
|
||||||
|
} else if (path === '/api/backups/import' && method === 'POST') {
|
||||||
|
return await this.handleImportBackupRequest(req);
|
||||||
} else if (path === '/api/settings/backup-password' && method === 'POST') {
|
} else if (path === '/api/settings/backup-password' && method === 'POST') {
|
||||||
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') {
|
||||||
@@ -2278,6 +2280,118 @@ export class OneboxHttpServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import a backup from file upload or URL
|
||||||
|
*/
|
||||||
|
private async handleImportBackupRequest(req: Request): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const contentType = req.headers.get('content-type') || '';
|
||||||
|
let filePath: string | null = null;
|
||||||
|
let newServiceName: string | undefined;
|
||||||
|
let tempFile = false;
|
||||||
|
|
||||||
|
if (contentType.includes('multipart/form-data')) {
|
||||||
|
// Handle file upload
|
||||||
|
const formData = await req.formData();
|
||||||
|
const file = formData.get('file');
|
||||||
|
newServiceName = formData.get('newServiceName')?.toString() || undefined;
|
||||||
|
|
||||||
|
if (!file || !(file instanceof File)) {
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: 'No file provided',
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file extension
|
||||||
|
if (!file.name.endsWith('.tar.enc')) {
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid file format. Expected .tar.enc file',
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to temp location
|
||||||
|
const tempDir = './.nogit/temp-imports';
|
||||||
|
await Deno.mkdir(tempDir, { recursive: true });
|
||||||
|
filePath = `${tempDir}/${Date.now()}-${file.name}`;
|
||||||
|
tempFile = true;
|
||||||
|
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
await Deno.writeFile(filePath, new Uint8Array(arrayBuffer));
|
||||||
|
|
||||||
|
logger.info(`Saved uploaded backup to ${filePath}`);
|
||||||
|
} else {
|
||||||
|
// Handle JSON body with URL
|
||||||
|
const body = await req.json();
|
||||||
|
const { url, newServiceName: serviceName } = body;
|
||||||
|
newServiceName = serviceName;
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: 'URL is required when not uploading a file',
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download from URL
|
||||||
|
const tempDir = './.nogit/temp-imports';
|
||||||
|
await Deno.mkdir(tempDir, { recursive: true });
|
||||||
|
|
||||||
|
const urlFilename = url.split('/').pop() || 'backup.tar.enc';
|
||||||
|
filePath = `${tempDir}/${Date.now()}-${urlFilename}`;
|
||||||
|
tempFile = true;
|
||||||
|
|
||||||
|
logger.info(`Downloading backup from ${url}...`);
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: `Failed to download from URL: ${response.statusText}`,
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
|
await Deno.writeFile(filePath, new Uint8Array(arrayBuffer));
|
||||||
|
|
||||||
|
logger.info(`Downloaded backup to ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import using restoreBackup with mode='import'
|
||||||
|
const result = await this.oneboxRef.backupManager.restoreBackup(filePath, {
|
||||||
|
mode: 'import',
|
||||||
|
newServiceName,
|
||||||
|
overwriteExisting: false,
|
||||||
|
skipPlatformData: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up temp file
|
||||||
|
if (tempFile && filePath) {
|
||||||
|
try {
|
||||||
|
await Deno.remove(filePath);
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: true,
|
||||||
|
message: `Backup imported successfully as service '${result.service.name}'`,
|
||||||
|
data: {
|
||||||
|
service: result.service,
|
||||||
|
platformResourcesRestored: result.platformResourcesRestored,
|
||||||
|
warnings: result.warnings,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to import backup: ${getErrorMessage(error)}`);
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error) || 'Failed to import backup',
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set backup encryption password
|
* Set backup encryption password
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -243,6 +243,45 @@ export class ApiService {
|
|||||||
return `/api/backups/${backupId}/download`;
|
return `/api/backups/${backupId}/download`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async downloadBackup(backupId: number, filename: string): Promise<void> {
|
||||||
|
const token = localStorage.getItem('onebox_token');
|
||||||
|
const response = await fetch(`/api/backups/${backupId}/download`, {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Download failed');
|
||||||
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async importBackupFromFile(file: File, newServiceName?: string): Promise<IApiResponse<IRestoreResult>> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
if (newServiceName) {
|
||||||
|
formData.append('newServiceName', newServiceName);
|
||||||
|
}
|
||||||
|
return firstValueFrom(
|
||||||
|
this.http.post<IApiResponse<IRestoreResult>>('/api/backups/import', formData)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async importBackupFromUrl(url: string, newServiceName?: string): Promise<IApiResponse<IRestoreResult>> {
|
||||||
|
return firstValueFrom(
|
||||||
|
this.http.post<IApiResponse<IRestoreResult>>('/api/backups/import', {
|
||||||
|
url,
|
||||||
|
newServiceName,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async restoreBackup(backupId: number, options: IRestoreOptions): Promise<IApiResponse<IRestoreResult>> {
|
async restoreBackup(backupId: number, options: IRestoreOptions): Promise<IApiResponse<IRestoreResult>> {
|
||||||
return firstValueFrom(
|
return firstValueFrom(
|
||||||
this.http.post<IApiResponse<IRestoreResult>>('/api/backups/restore', {
|
this.http.post<IApiResponse<IRestoreResult>>('/api/backups/restore', {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Component, inject, signal, computed, OnInit } from '@angular/core';
|
import { Component, inject, signal, computed, OnInit } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
import { ApiService } from '../../core/services/api.service';
|
import { ApiService } from '../../core/services/api.service';
|
||||||
import { ToastService } from '../../core/services/toast.service';
|
import { ToastService } from '../../core/services/toast.service';
|
||||||
import {
|
import {
|
||||||
@@ -81,6 +82,13 @@ import { SelectComponent, SelectOption } from '../../ui/select/select.component'
|
|||||||
<ui-card-title>Backup Schedules</ui-card-title>
|
<ui-card-title>Backup Schedules</ui-card-title>
|
||||||
<ui-card-description>Configure automated backup schedules for your services</ui-card-description>
|
<ui-card-description>Configure automated backup schedules for your services</ui-card-description>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button uiButton variant="outline" (click)="openImportDialog()">
|
||||||
|
<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>
|
||||||
|
Import Backup
|
||||||
|
</button>
|
||||||
<button uiButton (click)="openCreateScheduleDialog()">
|
<button uiButton (click)="openCreateScheduleDialog()">
|
||||||
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<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" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
||||||
@@ -88,6 +96,7 @@ import { SelectComponent, SelectOption } from '../../ui/select/select.component'
|
|||||||
Create Schedule
|
Create Schedule
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</ui-card-header>
|
</ui-card-header>
|
||||||
<ui-card-content class="p-0">
|
<ui-card-content class="p-0">
|
||||||
@if (schedulesLoading() && schedules().length === 0) {
|
@if (schedulesLoading() && schedules().length === 0) {
|
||||||
@@ -252,15 +261,18 @@ import { SelectComponent, SelectOption } from '../../ui/select/select.component'
|
|||||||
</ui-table-cell>
|
</ui-table-cell>
|
||||||
<ui-table-cell class="text-right">
|
<ui-table-cell class="text-right">
|
||||||
<div class="flex items-center justify-end gap-2">
|
<div class="flex items-center justify-end gap-2">
|
||||||
<a
|
<button
|
||||||
[href]="getBackupDownloadUrl(backup.id!)"
|
uiButton
|
||||||
download
|
variant="outline"
|
||||||
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"
|
size="sm"
|
||||||
|
(click)="downloadBackup(backup)"
|
||||||
|
[disabled]="backupActionLoading() === backup.id"
|
||||||
|
title="Download backup"
|
||||||
>
|
>
|
||||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<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" />
|
<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>
|
</svg>
|
||||||
</a>
|
</button>
|
||||||
<button
|
<button
|
||||||
uiButton
|
uiButton
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
@@ -475,11 +487,128 @@ import { SelectComponent, SelectOption } from '../../ui/select/select.component'
|
|||||||
</button>
|
</button>
|
||||||
</ui-dialog-footer>
|
</ui-dialog-footer>
|
||||||
</ui-dialog>
|
</ui-dialog>
|
||||||
|
|
||||||
|
<!-- Import Backup Dialog -->
|
||||||
|
<ui-dialog [open]="importDialogOpen()" (openChange)="importDialogOpen.set($event)">
|
||||||
|
<ui-dialog-header>
|
||||||
|
<ui-dialog-title>Import Backup</ui-dialog-title>
|
||||||
|
<ui-dialog-description>
|
||||||
|
Import a backup file to create a new service. The backup will be decrypted and the service restored.
|
||||||
|
</ui-dialog-description>
|
||||||
|
</ui-dialog-header>
|
||||||
|
<div class="space-y-4 py-4">
|
||||||
|
<!-- Import Mode Tabs -->
|
||||||
|
<div class="flex border-b">
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors"
|
||||||
|
[class.border-primary]="importMode() === 'file'"
|
||||||
|
[class.text-primary]="importMode() === 'file'"
|
||||||
|
[class.border-transparent]="importMode() !== 'file'"
|
||||||
|
[class.text-muted-foreground]="importMode() !== 'file'"
|
||||||
|
(click)="importMode.set('file')"
|
||||||
|
>
|
||||||
|
Upload File
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors"
|
||||||
|
[class.border-primary]="importMode() === 'url'"
|
||||||
|
[class.text-primary]="importMode() === 'url'"
|
||||||
|
[class.border-transparent]="importMode() !== 'url'"
|
||||||
|
[class.text-muted-foreground]="importMode() !== 'url'"
|
||||||
|
(click)="importMode.set('url')"
|
||||||
|
>
|
||||||
|
From URL
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (importMode() === 'file') {
|
||||||
|
<!-- File Upload -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label uiLabel>Backup File</label>
|
||||||
|
<div
|
||||||
|
class="border-2 border-dashed rounded-lg p-6 text-center cursor-pointer hover:border-primary/50 transition-colors"
|
||||||
|
[class.border-primary]="importFile()"
|
||||||
|
(click)="fileInput.click()"
|
||||||
|
(dragover)="onDragOver($event)"
|
||||||
|
(drop)="onFileDrop($event)"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
#fileInput
|
||||||
|
type="file"
|
||||||
|
accept=".tar.enc"
|
||||||
|
class="hidden"
|
||||||
|
(change)="onFileSelect($event)"
|
||||||
|
/>
|
||||||
|
@if (importFile()) {
|
||||||
|
<div class="flex items-center justify-center gap-2">
|
||||||
|
<svg class="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span class="font-medium">{{ importFile()?.name }}</span>
|
||||||
|
<span class="text-muted-foreground text-sm">({{ formatFileSize(importFile()?.size || 0) }})</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<svg class="mx-auto h-10 w-10 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||||
|
</svg>
|
||||||
|
<p class="mt-2 text-sm text-muted-foreground">Click to select or drag and drop</p>
|
||||||
|
<p class="text-xs text-muted-foreground">Accepts .tar.enc files</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<!-- URL Input -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label uiLabel>Backup URL</label>
|
||||||
|
<input
|
||||||
|
uiInput
|
||||||
|
type="url"
|
||||||
|
placeholder="https://example.com/backup.tar.enc"
|
||||||
|
[value]="importUrl()"
|
||||||
|
(input)="importUrl.set($any($event.target).value)"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-muted-foreground">URL to a .tar.enc backup file</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Service Name -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label uiLabel>Service Name (optional)</label>
|
||||||
|
<input
|
||||||
|
uiInput
|
||||||
|
type="text"
|
||||||
|
placeholder="my-service"
|
||||||
|
[value]="importServiceName()"
|
||||||
|
(input)="importServiceName.set($any($event.target).value)"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-muted-foreground">Leave empty to use the name from the backup manifest</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ui-dialog-footer>
|
||||||
|
<button uiButton variant="outline" (click)="closeImportDialog()">Cancel</button>
|
||||||
|
<button
|
||||||
|
uiButton
|
||||||
|
(click)="importBackup()"
|
||||||
|
[disabled]="importLoading() || !isImportValid()"
|
||||||
|
>
|
||||||
|
@if (importLoading()) {
|
||||||
|
<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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Importing...
|
||||||
|
} @else {
|
||||||
|
Import
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</ui-dialog-footer>
|
||||||
|
</ui-dialog>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class BackupsTabComponent implements OnInit {
|
export class BackupsTabComponent implements OnInit {
|
||||||
private api = inject(ApiService);
|
private api = inject(ApiService);
|
||||||
private toast = inject(ToastService);
|
private toast = inject(ToastService);
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
// Data
|
// Data
|
||||||
services = signal<IService[]>([]);
|
services = signal<IService[]>([]);
|
||||||
@@ -496,6 +625,14 @@ export class BackupsTabComponent implements OnInit {
|
|||||||
createScheduleDialogOpen = signal(false);
|
createScheduleDialogOpen = signal(false);
|
||||||
deleteScheduleDialogOpen = signal(false);
|
deleteScheduleDialogOpen = signal(false);
|
||||||
deleteBackupDialogOpen = signal(false);
|
deleteBackupDialogOpen = signal(false);
|
||||||
|
importDialogOpen = signal(false);
|
||||||
|
|
||||||
|
// Import dialog state
|
||||||
|
importMode = signal<'file' | 'url'>('file');
|
||||||
|
importFile = signal<File | null>(null);
|
||||||
|
importUrl = signal('');
|
||||||
|
importServiceName = signal('');
|
||||||
|
importLoading = signal(false);
|
||||||
|
|
||||||
// Dialog data
|
// Dialog data
|
||||||
newSchedule = signal<Partial<IBackupScheduleCreate>>({
|
newSchedule = signal<Partial<IBackupScheduleCreate>>({
|
||||||
@@ -703,8 +840,16 @@ export class BackupsTabComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Backup actions
|
// Backup actions
|
||||||
getBackupDownloadUrl(backupId: number): string {
|
async downloadBackup(backup: IBackup): Promise<void> {
|
||||||
return this.api.getBackupDownloadUrl(backupId);
|
this.backupActionLoading.set(backup.id!);
|
||||||
|
try {
|
||||||
|
await this.api.downloadBackup(backup.id!, backup.filename);
|
||||||
|
this.toast.success('Backup download started');
|
||||||
|
} catch {
|
||||||
|
this.toast.error('Failed to download backup');
|
||||||
|
} finally {
|
||||||
|
this.backupActionLoading.set(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
confirmDeleteBackup(backup: IBackup): void {
|
confirmDeleteBackup(backup: IBackup): void {
|
||||||
@@ -813,4 +958,86 @@ export class BackupsTabComponent implements OnInit {
|
|||||||
if (retention.monthly > 0) parts.push(`${retention.monthly} monthly`);
|
if (retention.monthly > 0) parts.push(`${retention.monthly} monthly`);
|
||||||
return parts.length > 0 ? `Keep: ${parts.join(', ')}` : 'No retention configured';
|
return parts.length > 0 ? `Keep: ${parts.join(', ')}` : 'No retention configured';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Import dialog methods
|
||||||
|
openImportDialog(): void {
|
||||||
|
this.importMode.set('file');
|
||||||
|
this.importFile.set(null);
|
||||||
|
this.importUrl.set('');
|
||||||
|
this.importServiceName.set('');
|
||||||
|
this.importDialogOpen.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeImportDialog(): void {
|
||||||
|
this.importDialogOpen.set(false);
|
||||||
|
this.importFile.set(null);
|
||||||
|
this.importUrl.set('');
|
||||||
|
this.importServiceName.set('');
|
||||||
|
}
|
||||||
|
|
||||||
|
onDragOver(event: DragEvent): void {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
onFileDrop(event: DragEvent): void {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const files = event.dataTransfer?.files;
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
this.importFile.set(files[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onFileSelect(event: Event): void {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
if (input.files && input.files.length > 0) {
|
||||||
|
this.importFile.set(input.files[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isImportValid(): boolean {
|
||||||
|
if (this.importMode() === 'file') {
|
||||||
|
return this.importFile() !== null;
|
||||||
|
} else {
|
||||||
|
const url = this.importUrl().trim();
|
||||||
|
return url.length > 0 && (url.startsWith('http://') || url.startsWith('https://'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatFileSize(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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async importBackup(): Promise<void> {
|
||||||
|
if (!this.isImportValid()) return;
|
||||||
|
|
||||||
|
this.importLoading.set(true);
|
||||||
|
try {
|
||||||
|
const serviceName = this.importServiceName().trim() || undefined;
|
||||||
|
let response;
|
||||||
|
|
||||||
|
if (this.importMode() === 'file') {
|
||||||
|
response = await this.api.importBackupFromFile(this.importFile()!, serviceName);
|
||||||
|
} else {
|
||||||
|
response = await this.api.importBackupFromUrl(this.importUrl().trim(), serviceName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
this.toast.success(`Service "${response.data.serviceName}" imported successfully`);
|
||||||
|
this.closeImportDialog();
|
||||||
|
// Navigate to the services list to see the new service
|
||||||
|
this.router.navigate(['/services']);
|
||||||
|
} else {
|
||||||
|
this.toast.error(response.error || 'Failed to import backup');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.toast.error('Failed to import backup');
|
||||||
|
} finally {
|
||||||
|
this.importLoading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -374,13 +374,11 @@ import {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<a [href]="getBackupDownloadUrl(backup.id!)" class="inline-flex" download>
|
<button uiButton variant="ghost" size="sm" (click)="downloadBackup(backup)" [disabled]="backupLoading()" title="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">
|
<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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</a>
|
|
||||||
<button uiButton variant="ghost" size="sm" (click)="openRestoreDialog(backup)" title="Restore">
|
<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">
|
<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" />
|
<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" />
|
||||||
@@ -1010,8 +1008,16 @@ export class ServiceDetailComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getBackupDownloadUrl(backupId: number): string {
|
async downloadBackup(backup: IBackup): Promise<void> {
|
||||||
return this.api.getBackupDownloadUrl(backupId);
|
this.backupLoading.set(true);
|
||||||
|
try {
|
||||||
|
await this.api.downloadBackup(backup.id!, backup.filename);
|
||||||
|
this.toast.success('Backup download started');
|
||||||
|
} catch {
|
||||||
|
this.toast.error('Failed to download backup');
|
||||||
|
} finally {
|
||||||
|
this.backupLoading.set(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
formatBytes(bytes: number): string {
|
formatBytes(bytes: number): string {
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { Component, inject, signal, effect, OnInit, OnDestroy } from '@angular/core';
|
import { Component, inject, signal, effect, OnInit, OnDestroy, ElementRef, ViewChild } from '@angular/core';
|
||||||
import { RouterLink, ActivatedRoute, Router } from '@angular/router';
|
import { RouterLink, ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Subscription } from 'rxjs';
|
import { Subscription } from 'rxjs';
|
||||||
import { ApiService } from '../../core/services/api.service';
|
import { ApiService } from '../../core/services/api.service';
|
||||||
import { WebSocketService } from '../../core/services/websocket.service';
|
import { WebSocketService } from '../../core/services/websocket.service';
|
||||||
import { ToastService } from '../../core/services/toast.service';
|
import { ToastService } from '../../core/services/toast.service';
|
||||||
import { IService, IPlatformService, TPlatformServiceType } from '../../core/types/api.types';
|
import { IService, IPlatformService, TPlatformServiceType } from '../../core/types/api.types';
|
||||||
|
import { InputComponent } from '../../ui/input/input.component';
|
||||||
|
import { LabelComponent } from '../../ui/label/label.component';
|
||||||
import {
|
import {
|
||||||
CardComponent,
|
CardComponent,
|
||||||
CardHeaderComponent,
|
CardHeaderComponent,
|
||||||
@@ -40,6 +43,7 @@ type TServicesTab = 'user' | 'system' | 'backups';
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
RouterLink,
|
RouterLink,
|
||||||
|
FormsModule,
|
||||||
CardComponent,
|
CardComponent,
|
||||||
CardHeaderComponent,
|
CardHeaderComponent,
|
||||||
CardTitleComponent,
|
CardTitleComponent,
|
||||||
@@ -62,6 +66,8 @@ type TServicesTab = 'user' | 'system' | 'backups';
|
|||||||
TabsComponent,
|
TabsComponent,
|
||||||
TabComponent,
|
TabComponent,
|
||||||
BackupsTabComponent,
|
BackupsTabComponent,
|
||||||
|
InputComponent,
|
||||||
|
LabelComponent,
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
@@ -72,6 +78,13 @@ type TServicesTab = 'user' | 'system' | 'backups';
|
|||||||
<p class="text-muted-foreground">Manage your deployed and system services</p>
|
<p class="text-muted-foreground">Manage your deployed and system services</p>
|
||||||
</div>
|
</div>
|
||||||
@if (activeTab() === 'user') {
|
@if (activeTab() === 'user') {
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button uiButton variant="outline" (click)="openImportDialog()">
|
||||||
|
<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>
|
||||||
|
Import Backup
|
||||||
|
</button>
|
||||||
<a [routerLink]="['/services/create']">
|
<a [routerLink]="['/services/create']">
|
||||||
<button uiButton>
|
<button uiButton>
|
||||||
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
@@ -80,6 +93,7 @@ type TServicesTab = 'user' | 'system' | 'backups';
|
|||||||
Deploy Service
|
Deploy Service
|
||||||
</button>
|
</button>
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -304,6 +318,122 @@ type TServicesTab = 'user' | 'system' | 'backups';
|
|||||||
</button>
|
</button>
|
||||||
</ui-dialog-footer>
|
</ui-dialog-footer>
|
||||||
</ui-dialog>
|
</ui-dialog>
|
||||||
|
|
||||||
|
<!-- Import Backup Dialog -->
|
||||||
|
<ui-dialog [open]="importDialogOpen()" (openChange)="importDialogOpen.set($event)">
|
||||||
|
<ui-dialog-header>
|
||||||
|
<ui-dialog-title>Import Backup</ui-dialog-title>
|
||||||
|
<ui-dialog-description>
|
||||||
|
Import a backup file to create a new service. The backup will be decrypted and the service restored.
|
||||||
|
</ui-dialog-description>
|
||||||
|
</ui-dialog-header>
|
||||||
|
<div class="space-y-4 py-4">
|
||||||
|
<!-- Import Mode Tabs -->
|
||||||
|
<div class="flex border-b">
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors"
|
||||||
|
[class.border-primary]="importMode() === 'file'"
|
||||||
|
[class.text-primary]="importMode() === 'file'"
|
||||||
|
[class.border-transparent]="importMode() !== 'file'"
|
||||||
|
[class.text-muted-foreground]="importMode() !== 'file'"
|
||||||
|
(click)="importMode.set('file')"
|
||||||
|
>
|
||||||
|
Upload File
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors"
|
||||||
|
[class.border-primary]="importMode() === 'url'"
|
||||||
|
[class.text-primary]="importMode() === 'url'"
|
||||||
|
[class.border-transparent]="importMode() !== 'url'"
|
||||||
|
[class.text-muted-foreground]="importMode() !== 'url'"
|
||||||
|
(click)="importMode.set('url')"
|
||||||
|
>
|
||||||
|
From URL
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (importMode() === 'file') {
|
||||||
|
<!-- File Upload -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label uiLabel>Backup File</label>
|
||||||
|
<div
|
||||||
|
class="border-2 border-dashed rounded-lg p-6 text-center cursor-pointer hover:border-primary/50 transition-colors"
|
||||||
|
[class.border-primary]="importFile()"
|
||||||
|
(click)="fileInput.click()"
|
||||||
|
(dragover)="onDragOver($event)"
|
||||||
|
(drop)="onFileDrop($event)"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
#fileInput
|
||||||
|
type="file"
|
||||||
|
accept=".tar.enc"
|
||||||
|
class="hidden"
|
||||||
|
(change)="onFileSelect($event)"
|
||||||
|
/>
|
||||||
|
@if (importFile()) {
|
||||||
|
<div class="flex items-center justify-center gap-2">
|
||||||
|
<svg class="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span class="font-medium">{{ importFile()?.name }}</span>
|
||||||
|
<span class="text-muted-foreground text-sm">({{ formatFileSize(importFile()?.size || 0) }})</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<svg class="mx-auto h-10 w-10 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||||
|
</svg>
|
||||||
|
<p class="mt-2 text-sm text-muted-foreground">Click to select or drag and drop</p>
|
||||||
|
<p class="text-xs text-muted-foreground">Accepts .tar.enc files</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<!-- URL Input -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label uiLabel>Backup URL</label>
|
||||||
|
<input
|
||||||
|
uiInput
|
||||||
|
type="url"
|
||||||
|
placeholder="https://example.com/backup.tar.enc"
|
||||||
|
[value]="importUrl()"
|
||||||
|
(input)="importUrl.set($any($event.target).value)"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-muted-foreground">URL to a .tar.enc backup file</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Service Name -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label uiLabel>Service Name (optional)</label>
|
||||||
|
<input
|
||||||
|
uiInput
|
||||||
|
type="text"
|
||||||
|
placeholder="my-service"
|
||||||
|
[value]="importServiceName()"
|
||||||
|
(input)="importServiceName.set($any($event.target).value)"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-muted-foreground">Leave empty to use the name from the backup manifest</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ui-dialog-footer>
|
||||||
|
<button uiButton variant="outline" (click)="closeImportDialog()">Cancel</button>
|
||||||
|
<button
|
||||||
|
uiButton
|
||||||
|
(click)="importBackup()"
|
||||||
|
[disabled]="importLoading() || !isImportValid()"
|
||||||
|
>
|
||||||
|
@if (importLoading()) {
|
||||||
|
<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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Importing...
|
||||||
|
} @else {
|
||||||
|
Import
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</ui-dialog-footer>
|
||||||
|
</ui-dialog>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class ServicesListComponent implements OnInit, OnDestroy {
|
export class ServicesListComponent implements OnInit, OnDestroy {
|
||||||
@@ -329,6 +459,14 @@ export class ServicesListComponent implements OnInit, OnDestroy {
|
|||||||
platformLoading = signal(false);
|
platformLoading = signal(false);
|
||||||
platformActionLoading = signal<TPlatformServiceType | null>(null);
|
platformActionLoading = signal<TPlatformServiceType | null>(null);
|
||||||
|
|
||||||
|
// Import dialog
|
||||||
|
importDialogOpen = signal(false);
|
||||||
|
importMode = signal<'file' | 'url'>('file');
|
||||||
|
importFile = signal<File | null>(null);
|
||||||
|
importUrl = signal('');
|
||||||
|
importServiceName = signal('');
|
||||||
|
importLoading = signal(false);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// React to WebSocket updates
|
// React to WebSocket updates
|
||||||
effect(() => {
|
effect(() => {
|
||||||
@@ -534,4 +672,100 @@ export class ServicesListComponent implements OnInit, OnDestroy {
|
|||||||
this.platformActionLoading.set(null);
|
this.platformActionLoading.set(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Import Dialog Methods
|
||||||
|
openImportDialog(): void {
|
||||||
|
this.importDialogOpen.set(true);
|
||||||
|
this.importMode.set('file');
|
||||||
|
this.importFile.set(null);
|
||||||
|
this.importUrl.set('');
|
||||||
|
this.importServiceName.set('');
|
||||||
|
}
|
||||||
|
|
||||||
|
closeImportDialog(): void {
|
||||||
|
this.importDialogOpen.set(false);
|
||||||
|
this.importFile.set(null);
|
||||||
|
this.importUrl.set('');
|
||||||
|
this.importServiceName.set('');
|
||||||
|
}
|
||||||
|
|
||||||
|
onDragOver(event: DragEvent): void {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
onFileDrop(event: DragEvent): void {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const files = event.dataTransfer?.files;
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
const file = files[0];
|
||||||
|
if (file.name.endsWith('.tar.enc')) {
|
||||||
|
this.importFile.set(file);
|
||||||
|
} else {
|
||||||
|
this.toast.error('Please select a .tar.enc backup file');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onFileSelect(event: Event): void {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
if (input.files && input.files.length > 0) {
|
||||||
|
const file = input.files[0];
|
||||||
|
if (file.name.endsWith('.tar.enc')) {
|
||||||
|
this.importFile.set(file);
|
||||||
|
} else {
|
||||||
|
this.toast.error('Please select a .tar.enc backup file');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isImportValid(): boolean {
|
||||||
|
if (this.importMode() === 'file') {
|
||||||
|
return this.importFile() !== null;
|
||||||
|
} else {
|
||||||
|
const url = this.importUrl().trim();
|
||||||
|
return url.length > 0 && (url.startsWith('http://') || url.startsWith('https://'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatFileSize(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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async importBackup(): Promise<void> {
|
||||||
|
if (!this.isImportValid()) return;
|
||||||
|
|
||||||
|
this.importLoading.set(true);
|
||||||
|
try {
|
||||||
|
let response;
|
||||||
|
const serviceName = this.importServiceName().trim() || undefined;
|
||||||
|
|
||||||
|
if (this.importMode() === 'file') {
|
||||||
|
const file = this.importFile();
|
||||||
|
if (!file) return;
|
||||||
|
response = await this.api.importBackupFromFile(file, serviceName);
|
||||||
|
} else {
|
||||||
|
const url = this.importUrl().trim();
|
||||||
|
response = await this.api.importBackupFromUrl(url, serviceName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
this.toast.success(`Service "${response.data.service.name}" imported successfully`);
|
||||||
|
this.closeImportDialog();
|
||||||
|
this.loadServices();
|
||||||
|
// Navigate to the new service
|
||||||
|
this.router.navigate(['/services/detail', response.data.service.name]);
|
||||||
|
} else {
|
||||||
|
this.toast.error(response.error || 'Failed to import backup');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.toast.error('Failed to import backup');
|
||||||
|
} finally {
|
||||||
|
this.importLoading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user