183 lines
5.9 KiB
TypeScript
183 lines
5.9 KiB
TypeScript
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||
import { Api } from '../../core/api';
|
||
import { Feedback } from '../../core/feedback';
|
||
import { errorMessage, formatBytes, formatDateTime, formatRelative } from '../../core/format';
|
||
import type { BackupFile, BackupSchedule } from '../../core/models';
|
||
import { Icon } from '../../shared/icon';
|
||
import { JobConsole } from '../../shared/job-console';
|
||
|
||
@Component({
|
||
selector: 'app-backups',
|
||
imports: [Icon, JobConsole],
|
||
template: `
|
||
<div class="page">
|
||
<header class="page-header">
|
||
<div>
|
||
<h1>Sauvegardes</h1>
|
||
<p>
|
||
Export de toutes les bases MariaDB et archive de <span class="mono">/srv</span> (sans
|
||
node_modules).
|
||
</p>
|
||
</div>
|
||
<button type="button" class="btn primary" (click)="run()" [disabled]="starting()">
|
||
<app-icon name="archive" /> Lancer une sauvegarde
|
||
</button>
|
||
</header>
|
||
|
||
@if (error()) {
|
||
<div class="alert critical" role="alert">
|
||
<app-icon name="critical" />
|
||
<p>{{ error() }}</p>
|
||
</div>
|
||
}
|
||
|
||
<div class="alert warning">
|
||
<app-icon name="alert" />
|
||
<p>
|
||
Les sauvegardes restent sur le VPS (<span class="mono">{{ dir() }}</span
|
||
>). Télécharge-les régulièrement : si le serveur est perdu, elles le sont aussi.
|
||
</p>
|
||
</div>
|
||
|
||
@if (jobId(); as id) {
|
||
<app-job-console [jobId]="id" (finished)="load()" />
|
||
}
|
||
|
||
<section class="grid-tiles">
|
||
<div class="card tile">
|
||
<span class="tile-label">Sauvegarde automatique</span>
|
||
<span class="tile-value small">{{
|
||
schedule()?.dailyAt ? 'Chaque jour à ' + schedule()!.dailyAt : 'Désactivée'
|
||
}}</span>
|
||
<span class="tile-sub">Variable BACKUP_DAILY_AT</span>
|
||
</div>
|
||
<div class="card tile">
|
||
<span class="tile-label">Conservation</span>
|
||
<span class="tile-value small">{{ schedule()?.keep ?? '–' }} de chaque type</span>
|
||
<span class="tile-sub">Les plus anciennes sont supprimées</span>
|
||
</div>
|
||
<div class="card tile">
|
||
<span class="tile-label">Dernière sauvegarde</span>
|
||
<span class="tile-value small">{{
|
||
files()[0] ? relative(files()[0].createdAt) : 'Aucune'
|
||
}}</span>
|
||
<span class="tile-sub">{{ files()[0] ? dateTime(files()[0].createdAt) : '' }}</span>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<div class="card-body flush table-wrap">
|
||
<table class="table">
|
||
<thead>
|
||
<tr>
|
||
<th>Fichier</th>
|
||
<th>Contenu</th>
|
||
<th class="num">Taille</th>
|
||
<th>Date</th>
|
||
<th class="actions"><span class="sr-only">Actions</span></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
@for (file of files(); track file.name) {
|
||
<tr>
|
||
<td class="mono">{{ file.name }}</td>
|
||
<td>{{ file.type === 'db' ? 'Bases de données' : 'Dossier /srv' }}</td>
|
||
<td class="num">{{ bytes(file.size) }}</td>
|
||
<td class="nowrap">{{ dateTime(file.createdAt) }}</td>
|
||
<td class="actions">
|
||
<a
|
||
class="btn small"
|
||
[href]="api.backupUrl(file.name)"
|
||
[attr.download]="file.name"
|
||
>
|
||
<app-icon name="download" /> Télécharger
|
||
</a>
|
||
<button
|
||
type="button"
|
||
class="btn small icon-only danger"
|
||
(click)="remove(file)"
|
||
[attr.aria-label]="'Supprimer ' + file.name"
|
||
>
|
||
<app-icon name="trash" />
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
} @empty {
|
||
<tr>
|
||
<td colspan="5" class="empty">Aucune sauvegarde pour l’instant.</td>
|
||
</tr>
|
||
}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
`,
|
||
styles: `
|
||
.tile-value.small {
|
||
font-size: 18px;
|
||
}
|
||
`,
|
||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||
})
|
||
export class BackupsPage {
|
||
protected api = inject(Api);
|
||
private feedback = inject(Feedback);
|
||
|
||
protected readonly files = signal<BackupFile[]>([]);
|
||
protected readonly schedule = signal<BackupSchedule | null>(null);
|
||
protected readonly dir = signal('');
|
||
protected readonly error = signal<string | null>(null);
|
||
protected readonly starting = signal(false);
|
||
protected readonly jobId = signal<number | null>(null);
|
||
|
||
protected readonly bytes = formatBytes;
|
||
protected readonly dateTime = formatDateTime;
|
||
protected readonly relative = formatRelative;
|
||
|
||
constructor() {
|
||
this.load();
|
||
}
|
||
|
||
async load() {
|
||
try {
|
||
const result = await this.api.backups();
|
||
this.files.set(result.files);
|
||
this.schedule.set(result.schedule);
|
||
this.dir.set(result.dir);
|
||
this.error.set(null);
|
||
} catch (error) {
|
||
this.error.set(errorMessage(error));
|
||
}
|
||
}
|
||
|
||
protected async run() {
|
||
this.starting.set(true);
|
||
try {
|
||
const { jobId } = await this.api.runBackup();
|
||
this.jobId.set(jobId);
|
||
} catch (error) {
|
||
this.feedback.error(errorMessage(error));
|
||
} finally {
|
||
this.starting.set(false);
|
||
}
|
||
}
|
||
|
||
protected async remove(file: BackupFile) {
|
||
const ok = await this.feedback.confirm({
|
||
title: 'Supprimer cette sauvegarde ?',
|
||
message: `${file.name} sera supprimé du serveur. C’est définitif.`,
|
||
confirmLabel: 'Supprimer',
|
||
danger: true,
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
await this.api.deleteBackup(file.name);
|
||
this.feedback.success('Sauvegarde supprimée.');
|
||
await this.load();
|
||
} catch (error) {
|
||
this.feedback.error(errorMessage(error));
|
||
}
|
||
}
|
||
}
|