|
|
|
|
@@ -0,0 +1,816 @@
|
|
|
|
|
import { Component, inject, signal, computed, OnInit } from '@angular/core';
|
|
|
|
|
import { CommonModule } from '@angular/common';
|
|
|
|
|
import { FormsModule } from '@angular/forms';
|
|
|
|
|
import { ApiService } from '../../core/services/api.service';
|
|
|
|
|
import { ToastService } from '../../core/services/toast.service';
|
|
|
|
|
import {
|
|
|
|
|
IService,
|
|
|
|
|
IBackup,
|
|
|
|
|
IBackupSchedule,
|
|
|
|
|
IBackupScheduleCreate,
|
|
|
|
|
IRetentionPolicy,
|
|
|
|
|
TRetentionPreset,
|
|
|
|
|
RETENTION_PRESETS,
|
|
|
|
|
} from '../../core/types/api.types';
|
|
|
|
|
import {
|
|
|
|
|
CardComponent,
|
|
|
|
|
CardHeaderComponent,
|
|
|
|
|
CardTitleComponent,
|
|
|
|
|
CardDescriptionComponent,
|
|
|
|
|
CardContentComponent,
|
|
|
|
|
} from '../../ui/card/card.component';
|
|
|
|
|
import { ButtonComponent } from '../../ui/button/button.component';
|
|
|
|
|
import { BadgeComponent } from '../../ui/badge/badge.component';
|
|
|
|
|
import {
|
|
|
|
|
TableComponent,
|
|
|
|
|
TableHeaderComponent,
|
|
|
|
|
TableBodyComponent,
|
|
|
|
|
TableRowComponent,
|
|
|
|
|
TableHeadComponent,
|
|
|
|
|
TableCellComponent,
|
|
|
|
|
} from '../../ui/table/table.component';
|
|
|
|
|
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
|
|
|
|
import {
|
|
|
|
|
DialogComponent,
|
|
|
|
|
DialogHeaderComponent,
|
|
|
|
|
DialogTitleComponent,
|
|
|
|
|
DialogDescriptionComponent,
|
|
|
|
|
DialogFooterComponent,
|
|
|
|
|
} from '../../ui/dialog/dialog.component';
|
|
|
|
|
import { InputComponent } from '../../ui/input/input.component';
|
|
|
|
|
import { LabelComponent } from '../../ui/label/label.component';
|
|
|
|
|
import { SelectComponent, SelectOption } from '../../ui/select/select.component';
|
|
|
|
|
|
|
|
|
|
@Component({
|
|
|
|
|
selector: 'app-backups-tab',
|
|
|
|
|
standalone: true,
|
|
|
|
|
host: { class: 'block' },
|
|
|
|
|
imports: [
|
|
|
|
|
CommonModule,
|
|
|
|
|
FormsModule,
|
|
|
|
|
CardComponent,
|
|
|
|
|
CardHeaderComponent,
|
|
|
|
|
CardTitleComponent,
|
|
|
|
|
CardDescriptionComponent,
|
|
|
|
|
CardContentComponent,
|
|
|
|
|
ButtonComponent,
|
|
|
|
|
BadgeComponent,
|
|
|
|
|
TableComponent,
|
|
|
|
|
TableHeaderComponent,
|
|
|
|
|
TableBodyComponent,
|
|
|
|
|
TableRowComponent,
|
|
|
|
|
TableHeadComponent,
|
|
|
|
|
TableCellComponent,
|
|
|
|
|
SkeletonComponent,
|
|
|
|
|
DialogComponent,
|
|
|
|
|
DialogHeaderComponent,
|
|
|
|
|
DialogTitleComponent,
|
|
|
|
|
DialogDescriptionComponent,
|
|
|
|
|
DialogFooterComponent,
|
|
|
|
|
InputComponent,
|
|
|
|
|
LabelComponent,
|
|
|
|
|
SelectComponent,
|
|
|
|
|
],
|
|
|
|
|
template: `
|
|
|
|
|
<div class="space-y-6">
|
|
|
|
|
<!-- Backup Schedules Card -->
|
|
|
|
|
<ui-card>
|
|
|
|
|
<ui-card-header>
|
|
|
|
|
<div class="flex items-center justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<ui-card-title>Backup Schedules</ui-card-title>
|
|
|
|
|
<ui-card-description>Configure automated backup schedules for your services</ui-card-description>
|
|
|
|
|
</div>
|
|
|
|
|
<button uiButton (click)="openCreateScheduleDialog()">
|
|
|
|
|
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
|
|
|
|
</svg>
|
|
|
|
|
Create Schedule
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</ui-card-header>
|
|
|
|
|
<ui-card-content class="p-0">
|
|
|
|
|
@if (schedulesLoading() && schedules().length === 0) {
|
|
|
|
|
<div class="p-6 space-y-4">
|
|
|
|
|
@for (_ of [1,2,3]; track $index) {
|
|
|
|
|
<ui-skeleton class="h-12 w-full" />
|
|
|
|
|
}
|
|
|
|
|
</div>
|
|
|
|
|
} @else if (schedules().length === 0) {
|
|
|
|
|
<div class="p-12 text-center">
|
|
|
|
|
<svg class="mx-auto h-12 w-12 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
|
|
|
</svg>
|
|
|
|
|
<h3 class="mt-4 text-lg font-semibold">No backup schedules</h3>
|
|
|
|
|
<p class="mt-2 text-sm text-muted-foreground">Create a schedule to automatically backup your services.</p>
|
|
|
|
|
</div>
|
|
|
|
|
} @else {
|
|
|
|
|
<ui-table>
|
|
|
|
|
<ui-table-header>
|
|
|
|
|
<ui-table-row>
|
|
|
|
|
<ui-table-head>Scope</ui-table-head>
|
|
|
|
|
<ui-table-head>Retention</ui-table-head>
|
|
|
|
|
<ui-table-head>Schedule</ui-table-head>
|
|
|
|
|
<ui-table-head>Last Run</ui-table-head>
|
|
|
|
|
<ui-table-head>Next Run</ui-table-head>
|
|
|
|
|
<ui-table-head>Status</ui-table-head>
|
|
|
|
|
<ui-table-head class="text-right">Actions</ui-table-head>
|
|
|
|
|
</ui-table-row>
|
|
|
|
|
</ui-table-header>
|
|
|
|
|
<ui-table-body>
|
|
|
|
|
@for (schedule of schedules(); track schedule.id) {
|
|
|
|
|
<ui-table-row>
|
|
|
|
|
<ui-table-cell class="font-medium">{{ getScopeDisplay(schedule) }}</ui-table-cell>
|
|
|
|
|
<ui-table-cell class="font-mono text-xs" [title]="getRetentionTooltip(schedule.retention)">
|
|
|
|
|
{{ getRetentionDisplay(schedule.retention) }}
|
|
|
|
|
</ui-table-cell>
|
|
|
|
|
<ui-table-cell class="text-muted-foreground font-mono text-xs">
|
|
|
|
|
{{ schedule.cronExpression }}
|
|
|
|
|
</ui-table-cell>
|
|
|
|
|
<ui-table-cell class="text-muted-foreground">
|
|
|
|
|
{{ schedule.lastRunAt ? formatDate(schedule.lastRunAt) : 'Never' }}
|
|
|
|
|
</ui-table-cell>
|
|
|
|
|
<ui-table-cell class="text-muted-foreground">
|
|
|
|
|
{{ schedule.nextRunAt ? formatDate(schedule.nextRunAt) : '-' }}
|
|
|
|
|
</ui-table-cell>
|
|
|
|
|
<ui-table-cell>
|
|
|
|
|
@if (!schedule.enabled) {
|
|
|
|
|
<ui-badge variant="secondary">Disabled</ui-badge>
|
|
|
|
|
} @else if (schedule.lastStatus === 'success') {
|
|
|
|
|
<ui-badge variant="success">Success</ui-badge>
|
|
|
|
|
} @else if (schedule.lastStatus === 'failed') {
|
|
|
|
|
<ui-badge variant="destructive" [title]="schedule.lastError || ''">Failed</ui-badge>
|
|
|
|
|
} @else {
|
|
|
|
|
<ui-badge variant="outline">Pending</ui-badge>
|
|
|
|
|
}
|
|
|
|
|
</ui-table-cell>
|
|
|
|
|
<ui-table-cell class="text-right">
|
|
|
|
|
<div class="flex items-center justify-end gap-2">
|
|
|
|
|
<button
|
|
|
|
|
uiButton
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
(click)="triggerBackup(schedule)"
|
|
|
|
|
[disabled]="scheduleActionLoading() === schedule.id"
|
|
|
|
|
title="Run backup now"
|
|
|
|
|
>
|
|
|
|
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
uiButton
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
(click)="toggleSchedule(schedule)"
|
|
|
|
|
[disabled]="scheduleActionLoading() === schedule.id"
|
|
|
|
|
[title]="schedule.enabled ? 'Disable' : 'Enable'"
|
|
|
|
|
>
|
|
|
|
|
@if (schedule.enabled) {
|
|
|
|
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
|
|
|
</svg>
|
|
|
|
|
} @else {
|
|
|
|
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
|
|
|
</svg>
|
|
|
|
|
}
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
uiButton
|
|
|
|
|
variant="destructive"
|
|
|
|
|
size="sm"
|
|
|
|
|
(click)="confirmDeleteSchedule(schedule)"
|
|
|
|
|
[disabled]="scheduleActionLoading() === schedule.id"
|
|
|
|
|
>
|
|
|
|
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</ui-table-cell>
|
|
|
|
|
</ui-table-row>
|
|
|
|
|
}
|
|
|
|
|
</ui-table-body>
|
|
|
|
|
</ui-table>
|
|
|
|
|
}
|
|
|
|
|
</ui-card-content>
|
|
|
|
|
</ui-card>
|
|
|
|
|
|
|
|
|
|
<!-- All Backups Card -->
|
|
|
|
|
<ui-card>
|
|
|
|
|
<ui-card-header>
|
|
|
|
|
<ui-card-title>All Backups</ui-card-title>
|
|
|
|
|
<ui-card-description>Browse and manage all backups across services</ui-card-description>
|
|
|
|
|
</ui-card-header>
|
|
|
|
|
<ui-card-content class="p-0">
|
|
|
|
|
@if (backupsLoading() && backups().length === 0) {
|
|
|
|
|
<div class="p-6 space-y-4">
|
|
|
|
|
@for (_ of [1,2,3]; track $index) {
|
|
|
|
|
<ui-skeleton class="h-12 w-full" />
|
|
|
|
|
}
|
|
|
|
|
</div>
|
|
|
|
|
} @else if (backups().length === 0) {
|
|
|
|
|
<div class="p-12 text-center">
|
|
|
|
|
<svg class="mx-auto h-12 w-12 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
|
|
|
|
|
</svg>
|
|
|
|
|
<h3 class="mt-4 text-lg font-semibold">No backups</h3>
|
|
|
|
|
<p class="mt-2 text-sm text-muted-foreground">Backups will appear here once created.</p>
|
|
|
|
|
</div>
|
|
|
|
|
} @else {
|
|
|
|
|
<ui-table>
|
|
|
|
|
<ui-table-header>
|
|
|
|
|
<ui-table-row>
|
|
|
|
|
<ui-table-head>Service</ui-table-head>
|
|
|
|
|
<ui-table-head>Created</ui-table-head>
|
|
|
|
|
<ui-table-head>Size</ui-table-head>
|
|
|
|
|
<ui-table-head>Includes</ui-table-head>
|
|
|
|
|
<ui-table-head class="text-right">Actions</ui-table-head>
|
|
|
|
|
</ui-table-row>
|
|
|
|
|
</ui-table-header>
|
|
|
|
|
<ui-table-body>
|
|
|
|
|
@for (backup of backups(); track backup.id) {
|
|
|
|
|
<ui-table-row>
|
|
|
|
|
<ui-table-cell class="font-medium">{{ backup.serviceName }}</ui-table-cell>
|
|
|
|
|
<ui-table-cell class="text-muted-foreground">{{ formatDate(backup.createdAt) }}</ui-table-cell>
|
|
|
|
|
<ui-table-cell class="text-muted-foreground">{{ formatSize(backup.sizeBytes) }}</ui-table-cell>
|
|
|
|
|
<ui-table-cell>
|
|
|
|
|
<div class="flex gap-1 flex-wrap">
|
|
|
|
|
@if (backup.includesImage) {
|
|
|
|
|
<ui-badge variant="outline">Image</ui-badge>
|
|
|
|
|
}
|
|
|
|
|
@for (resource of backup.platformResources; track resource) {
|
|
|
|
|
<ui-badge variant="secondary">{{ resource }}</ui-badge>
|
|
|
|
|
}
|
|
|
|
|
@if (!backup.includesImage && backup.platformResources.length === 0) {
|
|
|
|
|
<span class="text-muted-foreground text-sm">Config only</span>
|
|
|
|
|
}
|
|
|
|
|
</div>
|
|
|
|
|
</ui-table-cell>
|
|
|
|
|
<ui-table-cell class="text-right">
|
|
|
|
|
<div class="flex items-center justify-end gap-2">
|
|
|
|
|
<a
|
|
|
|
|
[href]="getBackupDownloadUrl(backup.id!)"
|
|
|
|
|
download
|
|
|
|
|
class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-8 px-3"
|
|
|
|
|
>
|
|
|
|
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
|
|
|
|
</svg>
|
|
|
|
|
</a>
|
|
|
|
|
<button
|
|
|
|
|
uiButton
|
|
|
|
|
variant="destructive"
|
|
|
|
|
size="sm"
|
|
|
|
|
(click)="confirmDeleteBackup(backup)"
|
|
|
|
|
[disabled]="backupActionLoading() === backup.id"
|
|
|
|
|
>
|
|
|
|
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</ui-table-cell>
|
|
|
|
|
</ui-table-row>
|
|
|
|
|
}
|
|
|
|
|
</ui-table-body>
|
|
|
|
|
</ui-table>
|
|
|
|
|
}
|
|
|
|
|
</ui-card-content>
|
|
|
|
|
</ui-card>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Create Schedule Dialog -->
|
|
|
|
|
<ui-dialog [open]="createScheduleDialogOpen()" (openChange)="createScheduleDialogOpen.set($event)">
|
|
|
|
|
<ui-dialog-header>
|
|
|
|
|
<ui-dialog-title>Create Backup Schedule</ui-dialog-title>
|
|
|
|
|
<ui-dialog-description>
|
|
|
|
|
Set up an automated backup schedule.
|
|
|
|
|
</ui-dialog-description>
|
|
|
|
|
</ui-dialog-header>
|
|
|
|
|
<div class="space-y-4 py-4">
|
|
|
|
|
<!-- Scope Type Selection -->
|
|
|
|
|
<div class="space-y-2">
|
|
|
|
|
<label uiLabel>Scope</label>
|
|
|
|
|
<div class="flex flex-col gap-2">
|
|
|
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
|
|
|
<input
|
|
|
|
|
type="radio"
|
|
|
|
|
name="scopeType"
|
|
|
|
|
value="all"
|
|
|
|
|
[checked]="newSchedule().scopeType === 'all'"
|
|
|
|
|
(change)="updateNewSchedule('scopeType', 'all')"
|
|
|
|
|
class="h-4 w-4"
|
|
|
|
|
/>
|
|
|
|
|
<span class="text-sm">All Services (global)</span>
|
|
|
|
|
</label>
|
|
|
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
|
|
|
<input
|
|
|
|
|
type="radio"
|
|
|
|
|
name="scopeType"
|
|
|
|
|
value="pattern"
|
|
|
|
|
[checked]="newSchedule().scopeType === 'pattern'"
|
|
|
|
|
(change)="updateNewSchedule('scopeType', 'pattern')"
|
|
|
|
|
class="h-4 w-4"
|
|
|
|
|
/>
|
|
|
|
|
<span class="text-sm">Pattern (glob)</span>
|
|
|
|
|
</label>
|
|
|
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
|
|
|
<input
|
|
|
|
|
type="radio"
|
|
|
|
|
name="scopeType"
|
|
|
|
|
value="service"
|
|
|
|
|
[checked]="newSchedule().scopeType === 'service'"
|
|
|
|
|
(change)="updateNewSchedule('scopeType', 'service')"
|
|
|
|
|
class="h-4 w-4"
|
|
|
|
|
/>
|
|
|
|
|
<span class="text-sm">Specific Service</span>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Pattern input (shown for pattern scope) -->
|
|
|
|
|
@if (newSchedule().scopeType === 'pattern') {
|
|
|
|
|
<div class="space-y-2">
|
|
|
|
|
<label uiLabel>Pattern</label>
|
|
|
|
|
<input
|
|
|
|
|
uiInput
|
|
|
|
|
[value]="newSchedule().scopePattern || ''"
|
|
|
|
|
(input)="updateNewSchedule('scopePattern', $any($event.target).value)"
|
|
|
|
|
placeholder="test-*, prod-*, *-service"
|
|
|
|
|
/>
|
|
|
|
|
<p class="text-sm text-muted-foreground">
|
|
|
|
|
Use * to match any characters (e.g., test-* matches test-api, test-web)
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
<!-- Service select (shown for service scope) -->
|
|
|
|
|
@if (newSchedule().scopeType === 'service') {
|
|
|
|
|
<div class="space-y-2">
|
|
|
|
|
<label uiLabel>Service</label>
|
|
|
|
|
<ui-select
|
|
|
|
|
[options]="serviceOptions()"
|
|
|
|
|
[value]="newSchedule().serviceName || ''"
|
|
|
|
|
(valueChange)="updateNewSchedule('serviceName', $event)"
|
|
|
|
|
placeholder="Select a service..."
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
<div class="space-y-2">
|
|
|
|
|
<label uiLabel>Cron Expression</label>
|
|
|
|
|
<input
|
|
|
|
|
uiInput
|
|
|
|
|
[value]="newSchedule().cronExpression || '0 2 * * *'"
|
|
|
|
|
(input)="updateNewSchedule('cronExpression', $any($event.target).value)"
|
|
|
|
|
placeholder="0 2 * * *"
|
|
|
|
|
/>
|
|
|
|
|
<p class="text-sm text-muted-foreground">
|
|
|
|
|
e.g., "0 2 * * *" = daily at 2am, "0 2 * * 0" = weekly Sunday at 2am
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="space-y-2">
|
|
|
|
|
<label uiLabel>Retention Preset</label>
|
|
|
|
|
<ui-select
|
|
|
|
|
[options]="presetOptions"
|
|
|
|
|
[value]="selectedPreset()"
|
|
|
|
|
(valueChange)="selectPreset($event)"
|
|
|
|
|
placeholder="Select preset..."
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="space-y-3 p-3 border rounded-md bg-muted/30">
|
|
|
|
|
<p class="text-sm font-medium">Custom Retention (0 = disabled)</p>
|
|
|
|
|
<div class="grid grid-cols-2 gap-3">
|
|
|
|
|
<div class="space-y-1">
|
|
|
|
|
<label uiLabel class="text-xs">Hourly</label>
|
|
|
|
|
<input
|
|
|
|
|
uiInput
|
|
|
|
|
type="number"
|
|
|
|
|
min="0"
|
|
|
|
|
[value]="newScheduleRetention().hourly"
|
|
|
|
|
(input)="updateRetention('hourly', +$any($event.target).value)"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="space-y-1">
|
|
|
|
|
<label uiLabel class="text-xs">Daily</label>
|
|
|
|
|
<input
|
|
|
|
|
uiInput
|
|
|
|
|
type="number"
|
|
|
|
|
min="0"
|
|
|
|
|
[value]="newScheduleRetention().daily"
|
|
|
|
|
(input)="updateRetention('daily', +$any($event.target).value)"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="space-y-1">
|
|
|
|
|
<label uiLabel class="text-xs">Weekly</label>
|
|
|
|
|
<input
|
|
|
|
|
uiInput
|
|
|
|
|
type="number"
|
|
|
|
|
min="0"
|
|
|
|
|
[value]="newScheduleRetention().weekly"
|
|
|
|
|
(input)="updateRetention('weekly', +$any($event.target).value)"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="space-y-1">
|
|
|
|
|
<label uiLabel class="text-xs">Monthly</label>
|
|
|
|
|
<input
|
|
|
|
|
uiInput
|
|
|
|
|
type="number"
|
|
|
|
|
min="0"
|
|
|
|
|
[value]="newScheduleRetention().monthly"
|
|
|
|
|
(input)="updateRetention('monthly', +$any($event.target).value)"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<ui-dialog-footer>
|
|
|
|
|
<button uiButton variant="outline" (click)="createScheduleDialogOpen.set(false)">Cancel</button>
|
|
|
|
|
<button
|
|
|
|
|
uiButton
|
|
|
|
|
(click)="createSchedule()"
|
|
|
|
|
[disabled]="!isCreateScheduleValid() || scheduleActionLoading() !== null"
|
|
|
|
|
>
|
|
|
|
|
Create Schedule
|
|
|
|
|
</button>
|
|
|
|
|
</ui-dialog-footer>
|
|
|
|
|
</ui-dialog>
|
|
|
|
|
|
|
|
|
|
<!-- Delete Schedule Confirmation Dialog -->
|
|
|
|
|
<ui-dialog [open]="deleteScheduleDialogOpen()" (openChange)="deleteScheduleDialogOpen.set($event)">
|
|
|
|
|
<ui-dialog-header>
|
|
|
|
|
<ui-dialog-title>Delete Schedule</ui-dialog-title>
|
|
|
|
|
<ui-dialog-description>
|
|
|
|
|
Are you sure you want to delete this backup schedule for {{ scheduleToDelete() ? getScopeDisplay(scheduleToDelete()!) : '' }}?
|
|
|
|
|
This will not delete existing backups.
|
|
|
|
|
</ui-dialog-description>
|
|
|
|
|
</ui-dialog-header>
|
|
|
|
|
<ui-dialog-footer>
|
|
|
|
|
<button uiButton variant="outline" (click)="deleteScheduleDialogOpen.set(false)">Cancel</button>
|
|
|
|
|
<button uiButton variant="destructive" (click)="deleteSchedule()" [disabled]="scheduleActionLoading() !== null">
|
|
|
|
|
Delete
|
|
|
|
|
</button>
|
|
|
|
|
</ui-dialog-footer>
|
|
|
|
|
</ui-dialog>
|
|
|
|
|
|
|
|
|
|
<!-- Delete Backup Confirmation Dialog -->
|
|
|
|
|
<ui-dialog [open]="deleteBackupDialogOpen()" (openChange)="deleteBackupDialogOpen.set($event)">
|
|
|
|
|
<ui-dialog-header>
|
|
|
|
|
<ui-dialog-title>Delete Backup</ui-dialog-title>
|
|
|
|
|
<ui-dialog-description>
|
|
|
|
|
Are you sure you want to delete this backup for "{{ backupToDelete()?.serviceName }}"?
|
|
|
|
|
This action cannot be undone.
|
|
|
|
|
</ui-dialog-description>
|
|
|
|
|
</ui-dialog-header>
|
|
|
|
|
<ui-dialog-footer>
|
|
|
|
|
<button uiButton variant="outline" (click)="deleteBackupDialogOpen.set(false)">Cancel</button>
|
|
|
|
|
<button uiButton variant="destructive" (click)="deleteBackup()" [disabled]="backupActionLoading() !== null">
|
|
|
|
|
Delete
|
|
|
|
|
</button>
|
|
|
|
|
</ui-dialog-footer>
|
|
|
|
|
</ui-dialog>
|
|
|
|
|
`,
|
|
|
|
|
})
|
|
|
|
|
export class BackupsTabComponent implements OnInit {
|
|
|
|
|
private api = inject(ApiService);
|
|
|
|
|
private toast = inject(ToastService);
|
|
|
|
|
|
|
|
|
|
// Data
|
|
|
|
|
services = signal<IService[]>([]);
|
|
|
|
|
schedules = signal<IBackupSchedule[]>([]);
|
|
|
|
|
backups = signal<IBackup[]>([]);
|
|
|
|
|
|
|
|
|
|
// Loading states
|
|
|
|
|
schedulesLoading = signal(false);
|
|
|
|
|
backupsLoading = signal(false);
|
|
|
|
|
scheduleActionLoading = signal<number | null>(null);
|
|
|
|
|
backupActionLoading = signal<number | null>(null);
|
|
|
|
|
|
|
|
|
|
// Dialog states
|
|
|
|
|
createScheduleDialogOpen = signal(false);
|
|
|
|
|
deleteScheduleDialogOpen = signal(false);
|
|
|
|
|
deleteBackupDialogOpen = signal(false);
|
|
|
|
|
|
|
|
|
|
// Dialog data
|
|
|
|
|
newSchedule = signal<Partial<IBackupScheduleCreate>>({
|
|
|
|
|
scopeType: 'service',
|
|
|
|
|
cronExpression: '0 2 * * *',
|
|
|
|
|
retention: { ...RETENTION_PRESETS.standard },
|
|
|
|
|
});
|
|
|
|
|
newScheduleRetention = signal<IRetentionPolicy>({ ...RETENTION_PRESETS.standard });
|
|
|
|
|
selectedPreset = signal<TRetentionPreset>('standard');
|
|
|
|
|
scheduleToDelete = signal<IBackupSchedule | null>(null);
|
|
|
|
|
backupToDelete = signal<IBackup | null>(null);
|
|
|
|
|
|
|
|
|
|
// Computed options for selects
|
|
|
|
|
serviceOptions = computed(() =>
|
|
|
|
|
this.services().map((s) => ({ label: s.name, value: s.name }))
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
presetOptions: SelectOption[] = [
|
|
|
|
|
{ label: 'Standard (H:0, D:7, W:4, M:12)', value: 'standard' },
|
|
|
|
|
{ label: 'Frequent (H:24, D:7, W:4, M:12)', value: 'frequent' },
|
|
|
|
|
{ label: 'Minimal (H:0, D:3, W:2, M:6)', value: 'minimal' },
|
|
|
|
|
{ label: 'Long-term (H:0, D:14, W:8, M:24)', value: 'longterm' },
|
|
|
|
|
{ label: 'Custom', value: 'custom' },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
ngOnInit(): void {
|
|
|
|
|
this.loadServices();
|
|
|
|
|
this.loadSchedules();
|
|
|
|
|
this.loadBackups();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async loadServices(): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
const response = await this.api.getServices();
|
|
|
|
|
if (response.success && response.data) {
|
|
|
|
|
this.services.set(response.data);
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// Silently fail - services needed for schedule creation
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async loadSchedules(): Promise<void> {
|
|
|
|
|
this.schedulesLoading.set(true);
|
|
|
|
|
try {
|
|
|
|
|
const response = await this.api.getBackupSchedules();
|
|
|
|
|
if (response.success && response.data) {
|
|
|
|
|
this.schedules.set(response.data);
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
this.toast.error('Failed to load backup schedules');
|
|
|
|
|
} finally {
|
|
|
|
|
this.schedulesLoading.set(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async loadBackups(): Promise<void> {
|
|
|
|
|
this.backupsLoading.set(true);
|
|
|
|
|
try {
|
|
|
|
|
const response = await this.api.getBackups();
|
|
|
|
|
if (response.success && response.data) {
|
|
|
|
|
this.backups.set(response.data);
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
this.toast.error('Failed to load backups');
|
|
|
|
|
} finally {
|
|
|
|
|
this.backupsLoading.set(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Schedule actions
|
|
|
|
|
openCreateScheduleDialog(): void {
|
|
|
|
|
this.newSchedule.set({
|
|
|
|
|
scopeType: 'service',
|
|
|
|
|
cronExpression: '0 2 * * *',
|
|
|
|
|
retention: { ...RETENTION_PRESETS.standard },
|
|
|
|
|
});
|
|
|
|
|
this.newScheduleRetention.set({ ...RETENTION_PRESETS.standard });
|
|
|
|
|
this.selectedPreset.set('standard');
|
|
|
|
|
this.createScheduleDialogOpen.set(true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateNewSchedule(field: keyof IBackupScheduleCreate, value: string): void {
|
|
|
|
|
this.newSchedule.update((s) => ({ ...s, [field]: value }));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
selectPreset(preset: string): void {
|
|
|
|
|
this.selectedPreset.set(preset as TRetentionPreset);
|
|
|
|
|
if (preset !== 'custom' && preset in RETENTION_PRESETS) {
|
|
|
|
|
const retention = { ...RETENTION_PRESETS[preset as keyof typeof RETENTION_PRESETS] };
|
|
|
|
|
this.newScheduleRetention.set(retention);
|
|
|
|
|
this.newSchedule.update((s) => ({ ...s, retention }));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateRetention(field: keyof IRetentionPolicy, value: number): void {
|
|
|
|
|
this.selectedPreset.set('custom');
|
|
|
|
|
this.newScheduleRetention.update((r) => ({ ...r, [field]: value }));
|
|
|
|
|
this.newSchedule.update((s) => ({
|
|
|
|
|
...s,
|
|
|
|
|
retention: { ...this.newScheduleRetention() },
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async createSchedule(): Promise<void> {
|
|
|
|
|
const schedule = this.newSchedule();
|
|
|
|
|
if (!this.isCreateScheduleValid()) {
|
|
|
|
|
this.toast.error('Please fill in all required fields');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ensure retention is set
|
|
|
|
|
const createRequest: IBackupScheduleCreate = {
|
|
|
|
|
scopeType: schedule.scopeType!,
|
|
|
|
|
scopePattern: schedule.scopePattern,
|
|
|
|
|
serviceName: schedule.serviceName,
|
|
|
|
|
cronExpression: schedule.cronExpression || '0 2 * * *',
|
|
|
|
|
retention: this.newScheduleRetention(),
|
|
|
|
|
enabled: schedule.enabled,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
this.scheduleActionLoading.set(-1); // Use -1 for create action
|
|
|
|
|
try {
|
|
|
|
|
const response = await this.api.createBackupSchedule(createRequest);
|
|
|
|
|
if (response.success) {
|
|
|
|
|
const scopeDesc = this.getScheduleCreateScopeDisplay(schedule);
|
|
|
|
|
this.toast.success(`Backup schedule created for ${scopeDesc}`);
|
|
|
|
|
this.createScheduleDialogOpen.set(false);
|
|
|
|
|
this.loadSchedules();
|
|
|
|
|
} else {
|
|
|
|
|
this.toast.error(response.error || 'Failed to create schedule');
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
this.toast.error('Failed to create schedule');
|
|
|
|
|
} finally {
|
|
|
|
|
this.scheduleActionLoading.set(null);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async triggerBackup(schedule: IBackupSchedule): Promise<void> {
|
|
|
|
|
this.scheduleActionLoading.set(schedule.id!);
|
|
|
|
|
try {
|
|
|
|
|
const response = await this.api.triggerBackupSchedule(schedule.id!);
|
|
|
|
|
if (response.success) {
|
|
|
|
|
this.toast.success(`Backup triggered for ${this.getScopeDisplay(schedule)}`);
|
|
|
|
|
// Reload after a short delay to show the new backup
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
this.loadSchedules();
|
|
|
|
|
this.loadBackups();
|
|
|
|
|
}, 2000);
|
|
|
|
|
} else {
|
|
|
|
|
this.toast.error(response.error || 'Failed to trigger backup');
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
this.toast.error('Failed to trigger backup');
|
|
|
|
|
} finally {
|
|
|
|
|
this.scheduleActionLoading.set(null);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async toggleSchedule(schedule: IBackupSchedule): Promise<void> {
|
|
|
|
|
this.scheduleActionLoading.set(schedule.id!);
|
|
|
|
|
try {
|
|
|
|
|
const response = await this.api.updateBackupSchedule(schedule.id!, {
|
|
|
|
|
enabled: !schedule.enabled,
|
|
|
|
|
});
|
|
|
|
|
if (response.success) {
|
|
|
|
|
this.toast.success(`Schedule ${schedule.enabled ? 'disabled' : 'enabled'}`);
|
|
|
|
|
this.loadSchedules();
|
|
|
|
|
} else {
|
|
|
|
|
this.toast.error(response.error || 'Failed to update schedule');
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
this.toast.error('Failed to update schedule');
|
|
|
|
|
} finally {
|
|
|
|
|
this.scheduleActionLoading.set(null);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
confirmDeleteSchedule(schedule: IBackupSchedule): void {
|
|
|
|
|
this.scheduleToDelete.set(schedule);
|
|
|
|
|
this.deleteScheduleDialogOpen.set(true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async deleteSchedule(): Promise<void> {
|
|
|
|
|
const schedule = this.scheduleToDelete();
|
|
|
|
|
if (!schedule) return;
|
|
|
|
|
|
|
|
|
|
this.scheduleActionLoading.set(schedule.id!);
|
|
|
|
|
try {
|
|
|
|
|
const response = await this.api.deleteBackupSchedule(schedule.id!);
|
|
|
|
|
if (response.success) {
|
|
|
|
|
this.toast.success('Backup schedule deleted');
|
|
|
|
|
this.deleteScheduleDialogOpen.set(false);
|
|
|
|
|
this.loadSchedules();
|
|
|
|
|
} else {
|
|
|
|
|
this.toast.error(response.error || 'Failed to delete schedule');
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
this.toast.error('Failed to delete schedule');
|
|
|
|
|
} finally {
|
|
|
|
|
this.scheduleActionLoading.set(null);
|
|
|
|
|
this.scheduleToDelete.set(null);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Backup actions
|
|
|
|
|
getBackupDownloadUrl(backupId: number): string {
|
|
|
|
|
return this.api.getBackupDownloadUrl(backupId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
confirmDeleteBackup(backup: IBackup): void {
|
|
|
|
|
this.backupToDelete.set(backup);
|
|
|
|
|
this.deleteBackupDialogOpen.set(true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async deleteBackup(): Promise<void> {
|
|
|
|
|
const backup = this.backupToDelete();
|
|
|
|
|
if (!backup) return;
|
|
|
|
|
|
|
|
|
|
this.backupActionLoading.set(backup.id!);
|
|
|
|
|
try {
|
|
|
|
|
const response = await this.api.deleteBackup(backup.id!);
|
|
|
|
|
if (response.success) {
|
|
|
|
|
this.toast.success('Backup deleted');
|
|
|
|
|
this.deleteBackupDialogOpen.set(false);
|
|
|
|
|
this.loadBackups();
|
|
|
|
|
} else {
|
|
|
|
|
this.toast.error(response.error || 'Failed to delete backup');
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
this.toast.error('Failed to delete backup');
|
|
|
|
|
} finally {
|
|
|
|
|
this.backupActionLoading.set(null);
|
|
|
|
|
this.backupToDelete.set(null);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Helpers
|
|
|
|
|
formatDate(timestamp: number): string {
|
|
|
|
|
return new Date(timestamp).toLocaleString();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
formatSize(bytes: number): string {
|
|
|
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
|
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
|
|
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
|
|
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Scope helpers
|
|
|
|
|
getScopeDisplay(schedule: IBackupSchedule): string {
|
|
|
|
|
switch (schedule.scopeType) {
|
|
|
|
|
case 'all':
|
|
|
|
|
return 'All Services';
|
|
|
|
|
case 'pattern':
|
|
|
|
|
return `Pattern: ${schedule.scopePattern || ''}`;
|
|
|
|
|
case 'service':
|
|
|
|
|
return schedule.serviceName || 'Unknown Service';
|
|
|
|
|
default:
|
|
|
|
|
return schedule.serviceName || 'Unknown';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getScheduleCreateScopeDisplay(schedule: Partial<IBackupScheduleCreate>): string {
|
|
|
|
|
switch (schedule.scopeType) {
|
|
|
|
|
case 'all':
|
|
|
|
|
return 'all services';
|
|
|
|
|
case 'pattern':
|
|
|
|
|
return `pattern "${schedule.scopePattern || ''}"`;
|
|
|
|
|
case 'service':
|
|
|
|
|
return `"${schedule.serviceName || ''}"`;
|
|
|
|
|
default:
|
|
|
|
|
return 'unknown scope';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
isCreateScheduleValid(): boolean {
|
|
|
|
|
const schedule = this.newSchedule();
|
|
|
|
|
const retention = this.newScheduleRetention();
|
|
|
|
|
|
|
|
|
|
// Check retention is valid (at least one tier has count > 0)
|
|
|
|
|
const hasRetention = retention.hourly > 0 || retention.daily > 0 ||
|
|
|
|
|
retention.weekly > 0 || retention.monthly > 0;
|
|
|
|
|
if (!hasRetention) return false;
|
|
|
|
|
if (!schedule.scopeType) return false;
|
|
|
|
|
|
|
|
|
|
switch (schedule.scopeType) {
|
|
|
|
|
case 'all':
|
|
|
|
|
return true;
|
|
|
|
|
case 'pattern':
|
|
|
|
|
return !!schedule.scopePattern && schedule.scopePattern.trim().length > 0;
|
|
|
|
|
case 'service':
|
|
|
|
|
return !!schedule.serviceName && schedule.serviceName.trim().length > 0;
|
|
|
|
|
default:
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Retention display helpers
|
|
|
|
|
getRetentionDisplay(retention: IRetentionPolicy): string {
|
|
|
|
|
const parts: string[] = [];
|
|
|
|
|
if (retention.hourly > 0) parts.push(`H:${retention.hourly}`);
|
|
|
|
|
if (retention.daily > 0) parts.push(`D:${retention.daily}`);
|
|
|
|
|
if (retention.weekly > 0) parts.push(`W:${retention.weekly}`);
|
|
|
|
|
if (retention.monthly > 0) parts.push(`M:${retention.monthly}`);
|
|
|
|
|
return parts.length > 0 ? parts.join(', ') : 'None';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getRetentionTooltip(retention: IRetentionPolicy): string {
|
|
|
|
|
const parts: string[] = [];
|
|
|
|
|
if (retention.hourly > 0) parts.push(`${retention.hourly} hourly`);
|
|
|
|
|
if (retention.daily > 0) parts.push(`${retention.daily} daily`);
|
|
|
|
|
if (retention.weekly > 0) parts.push(`${retention.weekly} weekly`);
|
|
|
|
|
if (retention.monthly > 0) parts.push(`${retention.monthly} monthly`);
|
|
|
|
|
return parts.length > 0 ? `Keep: ${parts.join(', ')}` : 'No retention configured';
|
|
|
|
|
}
|
|
|
|
|
}
|