first commit
This commit is contained in:
commit
3c0479faf0
169 files changed
+27617
No files matched your search
@@ -0,0 +1,182 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Caddy</h1>
|
||||
<p>
|
||||
Reverse proxy et HTTPS.
|
||||
@if (info(); as i) {
|
||||
Fichier <span class="mono">{{ i.path }}</span
|
||||
>, conteneur <span class="mono">{{ i.container }}</span
|
||||
>.
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="btn" (click)="reload()" [disabled]="saving()">
|
||||
<app-icon name="refresh" /> Recharger Caddy
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@if (error()) {
|
||||
<div class="alert critical" role="alert">
|
||||
<app-icon name="critical" />
|
||||
<p>{{ error() }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="layout">
|
||||
<section class="card editor-card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h2>Caddyfile</h2>
|
||||
<p>{{ lineCount() }} lignes{{ dirty() ? ' · modifications non enregistrées' : '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body stack">
|
||||
<textarea
|
||||
class="textarea editor"
|
||||
spellcheck="false"
|
||||
autocapitalize="off"
|
||||
aria-label="Contenu du Caddyfile"
|
||||
[ngModel]="content()"
|
||||
(ngModelChange)="content.set($event)"
|
||||
(keydown)="onKeydown($event)"
|
||||
></textarea>
|
||||
|
||||
@if (output(); as out) {
|
||||
<div class="alert" [class.info]="out.ok" [class.critical]="!out.ok" role="status">
|
||||
<app-icon [name]="out.ok ? 'good' : 'critical'" />
|
||||
<div>
|
||||
<p>
|
||||
{{
|
||||
out.ok
|
||||
? 'Configuration appliquée.'
|
||||
: 'Caddy a refusé la configuration : le fichier précédent a été remis en place.'
|
||||
}}
|
||||
</p>
|
||||
@if (out.text && out.text !== 'OK') {
|
||||
<pre class="output">{{ out.text }}</pre>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="save-row">
|
||||
<input
|
||||
class="input"
|
||||
placeholder="Motif (facultatif), ex. : ajout de monprojet"
|
||||
[ngModel]="reason()"
|
||||
(ngModelChange)="reason.set($event)"
|
||||
aria-label="Motif de la modification"
|
||||
/>
|
||||
<button type="button" class="btn" (click)="reset()" [disabled]="!dirty() || saving()">
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn primary"
|
||||
(click)="save()"
|
||||
[disabled]="!dirty() || saving()"
|
||||
>
|
||||
@if (saving()) {
|
||||
<app-icon name="loader" class="spin" />
|
||||
} @else {
|
||||
<app-icon name="save" />
|
||||
}
|
||||
Enregistrer et recharger
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="stack">
|
||||
<section class="card">
|
||||
<div class="card-header"><h2>Sites déclarés</h2></div>
|
||||
<div class="card-body flush">
|
||||
<ul class="site-list">
|
||||
@for (site of info()?.sites ?? []; track site.line) {
|
||||
<li>
|
||||
<span class="mono">{{ site.addresses.join(', ') }}</span>
|
||||
<span class="muted">
|
||||
@if (site.kind === 'proxy') {
|
||||
→ {{ site.upstream }}
|
||||
} @else if (site.kind === 'static') {
|
||||
→ {{ site.upstream ?? 'fichiers' }}
|
||||
} @else {
|
||||
ligne {{ site.line }}
|
||||
}
|
||||
</span>
|
||||
</li>
|
||||
} @empty {
|
||||
<li class="muted">Aucun site</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h2>Versions précédentes</h2>
|
||||
<p>Sauvegardées avant chaque modification</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body flush">
|
||||
<ul class="version-list">
|
||||
@for (version of versions(); track version.id) {
|
||||
<li>
|
||||
<div>
|
||||
<strong>#{{ version.id }}</strong> · {{ dateTime(version.createdAt) }}
|
||||
<span class="muted"
|
||||
>{{ version.reason ?? ''
|
||||
}}{{ version.username ? ' · ' + version.username : '' }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="version-actions">
|
||||
<button type="button" class="btn small" (click)="view(version)">Voir</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn small"
|
||||
(click)="restore(version)"
|
||||
[disabled]="saving()"
|
||||
>
|
||||
Restaurer
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
} @empty {
|
||||
<li class="muted">
|
||||
Pas encore de version (elles apparaissent après la première modification).
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@if (viewed(); as version) {
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<h2>Version #{{ version.id }} · {{ dateTime(version.createdAt) }}</h2>
|
||||
<div class="toolbar">
|
||||
<button type="button" class="btn small" (click)="restore(version)" [disabled]="saving()">
|
||||
Restaurer cette version
|
||||
</button>
|
||||
<button type="button" class="btn small ghost" (click)="viewed.set(null)">Fermer</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<pre class="code-block">{{ version.content }}</pre>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,71 @@
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 340px;
|
||||
align-items: start;
|
||||
gap: 16px;
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.editor {
|
||||
min-height: 520px;
|
||||
white-space: pre;
|
||||
overflow-wrap: normal;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.save-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.output {
|
||||
max-height: 240px;
|
||||
margin: 8px 0 0;
|
||||
overflow: auto;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.site-list,
|
||||
.version-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 10px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.version-list li {
|
||||
gap: 8px;
|
||||
|
||||
.muted {
|
||||
display: block;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.version-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Api } from '../../core/api';
|
||||
import { Feedback } from '../../core/feedback';
|
||||
import { errorMessage, formatDateTime } from '../../core/format';
|
||||
import type { CaddyInfo, CaddyVersion } from '../../core/models';
|
||||
import { Icon } from '../../shared/icon';
|
||||
|
||||
@Component({
|
||||
selector: 'app-caddy',
|
||||
imports: [FormsModule, Icon],
|
||||
templateUrl: './caddy.html',
|
||||
styleUrl: './caddy.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class CaddyPage {
|
||||
private api = inject(Api);
|
||||
private feedback = inject(Feedback);
|
||||
|
||||
protected readonly info = signal<CaddyInfo | null>(null);
|
||||
protected readonly content = signal('');
|
||||
protected readonly reason = signal('');
|
||||
protected readonly saving = signal(false);
|
||||
protected readonly error = signal<string | null>(null);
|
||||
protected readonly output = signal<{ ok: boolean; text: string } | null>(null);
|
||||
protected readonly versions = signal<CaddyVersion[]>([]);
|
||||
protected readonly viewed = signal<CaddyVersion | null>(null);
|
||||
|
||||
protected readonly dirty = computed(
|
||||
() => this.info() !== null && this.content() !== this.info()!.content,
|
||||
);
|
||||
protected readonly lineCount = computed(() => this.content().split('\n').length);
|
||||
protected readonly dateTime = formatDateTime;
|
||||
|
||||
constructor() {
|
||||
this.load();
|
||||
}
|
||||
|
||||
protected async load() {
|
||||
try {
|
||||
const [info, versions] = await Promise.all([this.api.caddy(), this.api.caddyVersions()]);
|
||||
this.info.set(info);
|
||||
this.content.set(info.content);
|
||||
this.versions.set(versions);
|
||||
this.error.set(null);
|
||||
} catch (error) {
|
||||
this.error.set(errorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
protected async save() {
|
||||
if (!this.dirty()) return;
|
||||
const ok = await this.feedback.confirm({
|
||||
title: 'Enregistrer et recharger Caddy ?',
|
||||
message:
|
||||
'Si Caddy refuse la nouvelle configuration, l’ancien fichier est remis en place automatiquement.',
|
||||
confirmLabel: 'Enregistrer et recharger',
|
||||
});
|
||||
if (!ok) return;
|
||||
await this.run(
|
||||
() => this.api.saveCaddy(this.content(), this.reason().trim()),
|
||||
'Caddyfile enregistré et Caddy rechargé.',
|
||||
);
|
||||
}
|
||||
|
||||
protected async reload() {
|
||||
await this.run(() => this.api.reloadCaddy(), 'Caddy rechargé.');
|
||||
}
|
||||
|
||||
protected reset() {
|
||||
this.content.set(this.info()?.content ?? '');
|
||||
this.output.set(null);
|
||||
}
|
||||
|
||||
protected async view(version: CaddyVersion) {
|
||||
try {
|
||||
this.viewed.set(await this.api.caddyVersion(version.id));
|
||||
} catch (error) {
|
||||
this.feedback.error(errorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
protected async restore(version: CaddyVersion) {
|
||||
const ok = await this.feedback.confirm({
|
||||
title: `Restaurer la version #${version.id} ?`,
|
||||
message: `Version enregistrée le ${formatDateTime(version.createdAt)}. Le fichier actuel est lui-même gardé dans l’historique.`,
|
||||
confirmLabel: 'Restaurer',
|
||||
});
|
||||
if (!ok) return;
|
||||
this.viewed.set(null);
|
||||
await this.run(() => this.api.restoreCaddy(version.id), `Version #${version.id} restaurée.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab inserts a tab character (Caddyfile indentation) instead of
|
||||
* leaving the field.
|
||||
*/
|
||||
protected onKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Tab' || event.shiftKey) return;
|
||||
event.preventDefault();
|
||||
const textarea = event.target as HTMLTextAreaElement;
|
||||
const { selectionStart, selectionEnd, value } = textarea;
|
||||
const next = `${value.slice(0, selectionStart)}\t${value.slice(selectionEnd)}`;
|
||||
this.content.set(next);
|
||||
textarea.value = next;
|
||||
textarea.selectionStart = textarea.selectionEnd = selectionStart + 1;
|
||||
}
|
||||
|
||||
private async run(action: () => Promise<{ output: string }>, success: string) {
|
||||
this.saving.set(true);
|
||||
this.output.set(null);
|
||||
try {
|
||||
const result = await action();
|
||||
this.output.set({ ok: true, text: result.output || 'OK' });
|
||||
this.feedback.success(success);
|
||||
this.reason.set('');
|
||||
await this.load();
|
||||
} catch (error) {
|
||||
const body = error instanceof HttpErrorResponse ? error.error : null;
|
||||
this.output.set({ ok: false, text: body?.output || errorMessage(error) });
|
||||
this.feedback.error(errorMessage(error));
|
||||
} finally {
|
||||
this.saving.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Feedback } from '../../core/feedback';
|
||||
import type { ContainerAction } from '../../core/models';
|
||||
|
||||
/**
|
||||
* Asks before stopping or restarting a container (starting needs no
|
||||
* confirmation).
|
||||
*/
|
||||
export function confirmContainerAction(feedback: Feedback, name: string, action: ContainerAction) {
|
||||
if (action === 'start') return Promise.resolve(true);
|
||||
|
||||
const critical = ['caddy', 'mariadb'].includes(name);
|
||||
const consequence =
|
||||
name === 'caddy'
|
||||
? '\nTous les sites seront injoignables tant que Caddy ne tourne pas.'
|
||||
: name === 'mariadb'
|
||||
? '\nToutes les applications qui utilisent la base de données seront en panne.'
|
||||
: '';
|
||||
|
||||
return feedback.confirm({
|
||||
title: action === 'stop' ? `Arrêter ${name} ?` : `Redémarrer ${name} ?`,
|
||||
message:
|
||||
action === 'stop'
|
||||
? `Le conteneur restera arrêté jusqu’à ce que tu le relances.${consequence}`
|
||||
: `Le service sera coupé quelques secondes.${consequence}`,
|
||||
confirmLabel: action === 'stop' ? 'Arrêter' : 'Redémarrer',
|
||||
danger: action === 'stop' || critical,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<a routerLink="/containers" class="back-link"><app-icon name="back" /> Conteneurs</a>
|
||||
<h1>{{ container()?.name ?? id() }}</h1>
|
||||
@if (container(); as c) {
|
||||
<p class="mono">{{ c.image }}</p>
|
||||
}
|
||||
</div>
|
||||
@if (container(); as c) {
|
||||
<div class="toolbar">
|
||||
@if (c.state === 'running') {
|
||||
<button type="button" class="btn" (click)="act('restart')" [disabled]="busy()">
|
||||
<app-icon name="restart" /> Redémarrer
|
||||
</button>
|
||||
@if (!c.self) {
|
||||
<button type="button" class="btn danger" (click)="act('stop')" [disabled]="busy()">
|
||||
<app-icon name="stop" /> Arrêter
|
||||
</button>
|
||||
}
|
||||
} @else {
|
||||
<button type="button" class="btn primary" (click)="act('start')" [disabled]="busy()">
|
||||
<app-icon name="play" /> Démarrer
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</header>
|
||||
|
||||
@if (error()) {
|
||||
<div class="alert critical" role="alert">
|
||||
<app-icon name="critical" />
|
||||
<p>{{ error() }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (container(); as c) {
|
||||
<section class="grid-tiles">
|
||||
<div class="card tile">
|
||||
<span class="tile-label">État</span>
|
||||
@if (status(); as s) {
|
||||
<app-status [level]="s.level" [label]="s.label" />
|
||||
}
|
||||
<span class="tile-sub">
|
||||
@if (c.state === 'running') {
|
||||
Depuis le {{ format.dateTime(c.startedAt) }}
|
||||
} @else {
|
||||
Arrêté le {{ format.dateTime(c.finishedAt) }} (code {{ c.exitCode }})
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card tile">
|
||||
<span class="tile-label">Processeur</span>
|
||||
<span class="tile-value">{{ c.stats ? format.percent(c.stats.cpu) : '–' }}</span>
|
||||
<span class="tile-sub">du serveur entier</span>
|
||||
</div>
|
||||
<div class="card tile">
|
||||
<span class="tile-label">Mémoire</span>
|
||||
<span class="tile-value">{{ c.stats ? format.bytes(c.stats.memUsed) : '–' }}</span>
|
||||
<span class="tile-sub">limite {{ c.stats ? format.bytes(c.stats.memLimit) : '–' }}</span>
|
||||
</div>
|
||||
<div class="card tile">
|
||||
<span class="tile-label">Redémarrages</span>
|
||||
<span class="tile-value">{{ c.restartCount }}</span>
|
||||
<span class="tile-sub">politique : {{ c.restartPolicy || 'aucune' }}</span>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<section class="card logs-card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h2>Logs</h2>
|
||||
<p>
|
||||
@switch (streamState()) {
|
||||
@case ('open') {
|
||||
En direct
|
||||
}
|
||||
@case ('connecting') {
|
||||
Connexion…
|
||||
}
|
||||
@default {
|
||||
{{ streamMessage() }}
|
||||
}
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<input
|
||||
class="input filter"
|
||||
type="search"
|
||||
placeholder="Filtrer les lignes"
|
||||
[(ngModel)]="filter"
|
||||
aria-label="Filtrer les lignes"
|
||||
/>
|
||||
<select
|
||||
class="select tail"
|
||||
[ngModel]="tail()"
|
||||
(ngModelChange)="tail.set(+$event)"
|
||||
aria-label="Nombre de lignes chargées"
|
||||
>
|
||||
<option [ngValue]="100">100 lignes</option>
|
||||
<option [ngValue]="300">300 lignes</option>
|
||||
<option [ngValue]="1000">1 000 lignes</option>
|
||||
<option [ngValue]="5000">5 000 lignes</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="btn small"
|
||||
(click)="togglePause()"
|
||||
[attr.aria-pressed]="paused()"
|
||||
>
|
||||
<app-icon [name]="paused() ? 'play' : 'pause'" /> {{ paused() ? 'Reprendre' : 'Pause' }}
|
||||
</button>
|
||||
<label class="checkbox"><input type="checkbox" [(ngModel)]="follow" /> Suivre</label>
|
||||
<label class="checkbox"><input type="checkbox" [(ngModel)]="showTimestamps" /> Heure</label>
|
||||
<button
|
||||
type="button"
|
||||
class="btn small icon-only"
|
||||
(click)="download()"
|
||||
aria-label="Télécharger les logs"
|
||||
>
|
||||
<app-icon name="download" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn small icon-only"
|
||||
(click)="clear()"
|
||||
aria-label="Vider l’affichage"
|
||||
>
|
||||
<app-icon name="trash" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-box" #logBox>
|
||||
@for (line of visibleLines(); track $index) {
|
||||
<div class="line" [class.err]="line.stream === 'err'">
|
||||
@if (showTimestamps()) {
|
||||
<span class="ts">{{ format.time(line.ts) }}</span>
|
||||
}
|
||||
<span class="text">{{ line.text }}</span>
|
||||
</div>
|
||||
} @empty {
|
||||
<p class="muted">Aucune ligne.</p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (container(); as c) {
|
||||
<section class="grid-2">
|
||||
<div class="card">
|
||||
<div class="card-header"><h2>Informations</h2></div>
|
||||
<div class="card-body">
|
||||
<dl class="kv">
|
||||
<dt>Projet compose</dt>
|
||||
<dd>
|
||||
{{ c.composeProject ?? '–' }}
|
||||
@if (c.composeService) {
|
||||
<span class="muted">(service {{ c.composeService }})</span>
|
||||
}
|
||||
</dd>
|
||||
<dt>Dossier</dt>
|
||||
<dd class="mono">{{ c.composeWorkingDir ?? '–' }}</dd>
|
||||
<dt>Commande</dt>
|
||||
<dd class="mono">{{ c.command || '–' }}</dd>
|
||||
<dt>Réseaux</dt>
|
||||
<dd>{{ c.networks.join(', ') || '–' }}</dd>
|
||||
<dt>Ports publiés</dt>
|
||||
<dd class="mono">{{ c.publishedPorts.join(', ') || 'aucun (accès via Caddy)' }}</dd>
|
||||
<dt>Créé le</dt>
|
||||
<dd>{{ format.dateTime(c.createdAt) }}</dd>
|
||||
<dt>Id</dt>
|
||||
<dd class="mono">{{ c.id.slice(0, 12) }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header"><h2>Volumes</h2></div>
|
||||
<div class="card-body flush table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Source</th>
|
||||
<th>Dans le conteneur</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (mount of c.mounts; track mount.destination) {
|
||||
<tr>
|
||||
<td class="mono">{{ mount.source }}</td>
|
||||
<td class="mono">
|
||||
{{ mount.destination }}
|
||||
@if (mount.readOnly) {
|
||||
<span class="pill">lecture seule</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="2" class="muted">Aucun volume</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,71 @@
|
||||
.filter {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.tail {
|
||||
width: auto;
|
||||
min-height: 28px;
|
||||
padding: 3px 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.toolbar .checkbox {
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
|
||||
input {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.log-box {
|
||||
height: 480px;
|
||||
overflow: auto;
|
||||
padding: 10px 0;
|
||||
background: var(--surface-2);
|
||||
border-radius: 0 0 var(--radius) var(--radius);
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
|
||||
.muted {
|
||||
padding: 0 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.line {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 0 16px 0 13px;
|
||||
border-left: 3px solid transparent;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
|
||||
&:hover {
|
||||
background: var(--surface-3);
|
||||
}
|
||||
|
||||
&.err {
|
||||
border-left-color: var(--serious);
|
||||
}
|
||||
}
|
||||
|
||||
.ts {
|
||||
flex: none;
|
||||
color: var(--muted);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.filter {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.log-box {
|
||||
height: 60vh;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
untracked,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Api } from '../../core/api';
|
||||
import { Feedback } from '../../core/feedback';
|
||||
import {
|
||||
errorMessage,
|
||||
formatBytes,
|
||||
formatDateTime,
|
||||
formatPercent,
|
||||
formatTime,
|
||||
} from '../../core/format';
|
||||
import type { ContainerAction, ContainerDetails, LogLine } from '../../core/models';
|
||||
import { openStream } from '../../core/sse';
|
||||
import { Icon } from '../../shared/icon';
|
||||
import { Status, containerStatus } from '../../shared/status';
|
||||
import { confirmContainerAction } from './container-actions';
|
||||
|
||||
const MAX_LINES = 5000;
|
||||
|
||||
@Component({
|
||||
selector: 'app-container-detail',
|
||||
imports: [FormsModule, RouterLink, Icon, Status],
|
||||
templateUrl: './container-detail.html',
|
||||
styleUrl: './container-detail.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ContainerDetailPage {
|
||||
private api = inject(Api);
|
||||
private feedback = inject(Feedback);
|
||||
private logBox = viewChild<ElementRef<HTMLElement>>('logBox');
|
||||
|
||||
/**
|
||||
* Container name or id (route parameter).
|
||||
*/
|
||||
readonly id = input.required<string>();
|
||||
|
||||
protected readonly container = signal<ContainerDetails | null>(null);
|
||||
protected readonly error = signal<string | null>(null);
|
||||
protected readonly busy = signal(false);
|
||||
|
||||
protected readonly lines = signal<LogLine[]>([]);
|
||||
protected readonly tail = signal(300);
|
||||
protected readonly paused = signal(false);
|
||||
protected readonly follow = signal(true);
|
||||
protected readonly showTimestamps = signal(true);
|
||||
protected readonly filter = signal('');
|
||||
protected readonly streamState = signal<'connecting' | 'open' | 'ended' | 'error'>('connecting');
|
||||
protected readonly streamMessage = signal<string | null>(null);
|
||||
private pending: LogLine[] = [];
|
||||
|
||||
protected readonly visibleLines = computed(() => {
|
||||
const filter = this.filter().trim().toLowerCase();
|
||||
const lines = this.lines();
|
||||
return filter ? lines.filter((line) => line.text.toLowerCase().includes(filter)) : lines;
|
||||
});
|
||||
|
||||
protected readonly status = computed(() => {
|
||||
const container = this.container();
|
||||
return container ? containerStatus(container.state, container.health) : null;
|
||||
});
|
||||
|
||||
protected readonly format = {
|
||||
bytes: formatBytes,
|
||||
percent: (value: number) => formatPercent(value, 1),
|
||||
dateTime: formatDateTime,
|
||||
time: (ts: string | null) => (ts ? formatTime(ts, true) : ''),
|
||||
};
|
||||
|
||||
private closeStream: (() => void) | null = null;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const id = this.id();
|
||||
untracked(() => this.load(id));
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const id = this.id();
|
||||
const tail = this.tail();
|
||||
untracked(() => this.openLogs(id, tail));
|
||||
});
|
||||
|
||||
inject(DestroyRef).onDestroy(() => this.closeStream?.());
|
||||
}
|
||||
|
||||
protected async load(id = this.id()) {
|
||||
try {
|
||||
this.container.set(await this.api.container(id));
|
||||
this.error.set(null);
|
||||
} catch (error) {
|
||||
this.error.set(errorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
protected async act(action: ContainerAction) {
|
||||
const container = this.container();
|
||||
if (!container || !(await confirmContainerAction(this.feedback, container.name, action)))
|
||||
return;
|
||||
this.busy.set(true);
|
||||
try {
|
||||
this.container.set(await this.api.containerAction(container.id, action));
|
||||
this.feedback.success(`${container.name} : action effectuée.`);
|
||||
/**
|
||||
* A restarted container starts a new log stream.
|
||||
*/
|
||||
if (action !== 'stop') this.openLogs(this.id(), this.tail());
|
||||
} catch (error) {
|
||||
this.feedback.error(errorMessage(error));
|
||||
} finally {
|
||||
this.busy.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected togglePause() {
|
||||
const paused = !this.paused();
|
||||
this.paused.set(paused);
|
||||
if (!paused) this.flushPending();
|
||||
}
|
||||
|
||||
protected clear() {
|
||||
this.lines.set([]);
|
||||
this.pending = [];
|
||||
}
|
||||
|
||||
protected download() {
|
||||
const content = this.lines()
|
||||
.map((line) => `${line.ts ?? ''} ${line.text}`)
|
||||
.join('\n');
|
||||
const url = URL.createObjectURL(new Blob([content], { type: 'text/plain' }));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${this.container()?.name ?? 'conteneur'}-logs.txt`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
private openLogs(id: string, tail: number) {
|
||||
this.closeStream?.();
|
||||
this.lines.set([]);
|
||||
this.pending = [];
|
||||
this.streamState.set('connecting');
|
||||
this.streamMessage.set(null);
|
||||
|
||||
const url = `/api/containers/${encodeURIComponent(id)}/logs?tail=${tail}`;
|
||||
this.closeStream = openStream(
|
||||
url,
|
||||
{
|
||||
line: (line: LogLine) => {
|
||||
this.pending.push(line);
|
||||
if (!this.paused()) this.scheduleFlush();
|
||||
},
|
||||
end: () => {
|
||||
this.closeStream?.();
|
||||
this.flushPending();
|
||||
this.streamState.set('ended');
|
||||
this.streamMessage.set('Le flux s’est arrêté (conteneur arrêté ou redémarré).');
|
||||
},
|
||||
failure: ({ message }: { message: string }) => {
|
||||
this.closeStream?.();
|
||||
this.streamState.set('error');
|
||||
this.streamMessage.set(message);
|
||||
},
|
||||
},
|
||||
{
|
||||
onOpen: () => this.streamState.set('open'),
|
||||
onError: () => this.streamState.set('connecting'),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private flushScheduled = false;
|
||||
|
||||
/**
|
||||
* Lines arrive in bursts: they are added once per frame.
|
||||
*/
|
||||
private scheduleFlush() {
|
||||
if (this.flushScheduled) return;
|
||||
this.flushScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
this.flushScheduled = false;
|
||||
this.flushPending();
|
||||
});
|
||||
}
|
||||
|
||||
private flushPending() {
|
||||
if (!this.pending.length) return;
|
||||
const added = this.pending;
|
||||
this.pending = [];
|
||||
this.lines.update((lines) => {
|
||||
const next = lines.concat(added);
|
||||
return next.length > MAX_LINES ? next.slice(next.length - MAX_LINES) : next;
|
||||
});
|
||||
if (this.follow()) {
|
||||
requestAnimationFrame(() => {
|
||||
const box = this.logBox()?.nativeElement;
|
||||
if (box) box.scrollTop = box.scrollHeight;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Api } from '../../core/api';
|
||||
import { Feedback } from '../../core/feedback';
|
||||
import { errorMessage, formatBytes, formatPercent } from '../../core/format';
|
||||
import type { ContainerAction, ContainerSummary } from '../../core/models';
|
||||
import { Icon } from '../../shared/icon';
|
||||
import { Status, containerStatus } from '../../shared/status';
|
||||
import { confirmContainerAction } from './container-actions';
|
||||
|
||||
type StateFilter = 'all' | 'running' | 'stopped';
|
||||
|
||||
/**
|
||||
* Only Caddy should be reachable from outside (80 and 443). A port bound to
|
||||
* the loopback ("127.0.0.1:3306->3306/tcp") is not reachable either.
|
||||
*/
|
||||
const CADDY_PORTS = /^(80|443)->/;
|
||||
const LOCAL_ONLY = /^(127\.|::1:|\[::1\]:)/;
|
||||
|
||||
@Component({
|
||||
selector: 'app-containers',
|
||||
imports: [FormsModule, RouterLink, Icon, Status],
|
||||
template: `
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Conteneurs</h1>
|
||||
<p>Tous les conteneurs Docker du serveur, rafraîchis toutes les 10 secondes.</p>
|
||||
</div>
|
||||
<button type="button" class="btn" (click)="load()">
|
||||
<app-icon name="refresh" [class.spin]="loading()" /> Rafraîchir
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="toolbar">
|
||||
<input
|
||||
class="input search"
|
||||
type="search"
|
||||
placeholder="Filtrer par nom ou image"
|
||||
[(ngModel)]="search"
|
||||
aria-label="Filtrer"
|
||||
/>
|
||||
<div class="segmented" role="group" aria-label="État">
|
||||
<button
|
||||
type="button"
|
||||
[class.active]="stateFilter() === 'all'"
|
||||
(click)="stateFilter.set('all')"
|
||||
>
|
||||
Tous ({{ containers().length }})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
[class.active]="stateFilter() === 'running'"
|
||||
(click)="stateFilter.set('running')"
|
||||
>
|
||||
En marche ({{ runningCount() }})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
[class.active]="stateFilter() === 'stopped'"
|
||||
(click)="stateFilter.set('stopped')"
|
||||
>
|
||||
Arrêtés ({{ containers().length - runningCount() }})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (error()) {
|
||||
<div class="alert critical" role="alert">
|
||||
<app-icon name="critical" />
|
||||
<p>{{ error() }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (exposed().length) {
|
||||
<div class="alert warning" role="status">
|
||||
<app-icon name="alert" />
|
||||
<p>
|
||||
<strong>Ports publiés sur Internet :</strong> {{ exposed().join(', ') }}. Ces conteneurs
|
||||
sont joignables sans passer par Caddy (voir la procédure : pas de
|
||||
<code>ports:</code> dans les projets).
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<section class="card">
|
||||
<div class="card-body flush table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nom</th>
|
||||
<th>État</th>
|
||||
<th class="num">Processeur</th>
|
||||
<th class="num">Mémoire</th>
|
||||
<th>Image</th>
|
||||
<th class="actions"><span class="sr-only">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (container of filtered(); track container.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<a [routerLink]="['/containers', container.name]">{{ container.name }}</a>
|
||||
@if (container.self) {
|
||||
<span class="pill">ce tableau de bord</span>
|
||||
}
|
||||
<span class="sub">
|
||||
{{
|
||||
container.composeProject
|
||||
? 'Projet ' + container.composeProject
|
||||
: 'Hors compose'
|
||||
}}
|
||||
@for (port of container.publishedPorts; track port) {
|
||||
·
|
||||
<span class="port" [class.bad]="!isAllowedPort(container, port)">{{
|
||||
port
|
||||
}}</span>
|
||||
}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<app-status
|
||||
[level]="status(container).level"
|
||||
[label]="status(container).label"
|
||||
/>
|
||||
<span class="sub">{{ container.status }}</span>
|
||||
</td>
|
||||
<td class="num">{{ container.stats ? percent(container.stats.cpu) : '–' }}</td>
|
||||
<td class="num">{{ container.stats ? bytes(container.stats.memUsed) : '–' }}</td>
|
||||
<td class="image ellipsis mono" [title]="container.image">
|
||||
{{ container.image }}
|
||||
</td>
|
||||
<td class="actions">
|
||||
@if (container.state === 'running') {
|
||||
<button
|
||||
type="button"
|
||||
class="btn small"
|
||||
(click)="act(container, 'restart')"
|
||||
[disabled]="busy() === container.id"
|
||||
>
|
||||
<app-icon name="restart" /> Redémarrer
|
||||
</button>
|
||||
@if (!container.self) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn small icon-only danger"
|
||||
(click)="act(container, 'stop')"
|
||||
[disabled]="busy() === container.id"
|
||||
aria-label="Arrêter"
|
||||
>
|
||||
<app-icon name="stop" />
|
||||
</button>
|
||||
}
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
class="btn small"
|
||||
(click)="act(container, 'start')"
|
||||
[disabled]="busy() === container.id"
|
||||
>
|
||||
<app-icon name="play" /> Démarrer
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="6" class="empty">
|
||||
{{ loading() ? 'Chargement…' : 'Aucun conteneur' }}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
.search {
|
||||
width: min(320px, 100%);
|
||||
}
|
||||
.image {
|
||||
max-width: 220px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.port.bad {
|
||||
color: var(--critical-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.pill {
|
||||
margin-left: 6px;
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ContainersPage {
|
||||
private api = inject(Api);
|
||||
private feedback = inject(Feedback);
|
||||
|
||||
protected readonly containers = signal<ContainerSummary[]>([]);
|
||||
protected readonly loading = signal(true);
|
||||
protected readonly error = signal<string | null>(null);
|
||||
protected readonly busy = signal<string | null>(null);
|
||||
protected readonly search = signal('');
|
||||
protected readonly stateFilter = signal<StateFilter>('all');
|
||||
|
||||
protected readonly runningCount = computed(
|
||||
() => this.containers().filter((c) => c.state === 'running').length,
|
||||
);
|
||||
|
||||
protected readonly filtered = computed(() => {
|
||||
const search = this.search().trim().toLowerCase();
|
||||
const state = this.stateFilter();
|
||||
return this.containers().filter((container) => {
|
||||
if (state === 'running' && container.state !== 'running') return false;
|
||||
if (state === 'stopped' && container.state === 'running') return false;
|
||||
return (
|
||||
!search ||
|
||||
container.name.toLowerCase().includes(search) ||
|
||||
container.image.toLowerCase().includes(search)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
protected readonly exposed = computed(() =>
|
||||
this.containers()
|
||||
.filter((container) =>
|
||||
container.publishedPorts.some((port) => !this.isAllowedPort(container, port)),
|
||||
)
|
||||
.map((container) => container.name),
|
||||
);
|
||||
|
||||
protected readonly percent = (value: number) => formatPercent(value, 1);
|
||||
protected readonly bytes = formatBytes;
|
||||
protected readonly status = (container: ContainerSummary) =>
|
||||
containerStatus(container.state, container.health);
|
||||
|
||||
constructor() {
|
||||
this.load();
|
||||
const timer = setInterval(() => this.load(), 10_000);
|
||||
inject(DestroyRef).onDestroy(() => clearInterval(timer));
|
||||
}
|
||||
|
||||
protected isAllowedPort(container: ContainerSummary, port: string) {
|
||||
return LOCAL_ONLY.test(port) || (container.name === 'caddy' && CADDY_PORTS.test(port));
|
||||
}
|
||||
|
||||
async load() {
|
||||
try {
|
||||
this.containers.set(await this.api.containers());
|
||||
this.error.set(null);
|
||||
} catch (error) {
|
||||
this.error.set(errorMessage(error));
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected async act(container: ContainerSummary, action: ContainerAction) {
|
||||
if (!(await confirmContainerAction(this.feedback, container.name, action))) return;
|
||||
this.busy.set(container.id);
|
||||
try {
|
||||
await this.api.containerAction(container.id, action);
|
||||
this.feedback.success(`${container.name} : action effectuée.`);
|
||||
await this.load();
|
||||
} catch (error) {
|
||||
this.feedback.error(errorMessage(error));
|
||||
} finally {
|
||||
this.busy.set(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Icon } from '../../shared/icon';
|
||||
import { JobConsole } from '../../shared/job-console';
|
||||
|
||||
/**
|
||||
* Output of one action of the journal (live if it is still running).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-job',
|
||||
imports: [RouterLink, Icon, JobConsole],
|
||||
template: `
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<a routerLink="/journal" class="back-link"><app-icon name="back" /> Journal</a>
|
||||
<h1>Action #{{ jobId() }}</h1>
|
||||
</div>
|
||||
</header>
|
||||
<app-job-console [jobId]="jobId()" />
|
||||
</div>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class JobPage {
|
||||
readonly id = input.required<string>();
|
||||
protected readonly jobId = computed(() => Number(this.id()));
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Api } from '../../core/api';
|
||||
import { errorMessage, formatDateTime, formatDuration } from '../../core/format';
|
||||
import type { AuditEntry, AuditStatus } from '../../core/models';
|
||||
import { Icon } from '../../shared/icon';
|
||||
import { Status } from '../../shared/status';
|
||||
|
||||
const STATUS = {
|
||||
success: { level: 'good', label: 'Réussi' },
|
||||
error: { level: 'critical', label: 'Échec' },
|
||||
running: { level: 'running', label: 'En cours' },
|
||||
} as const;
|
||||
|
||||
@Component({
|
||||
selector: 'app-journal',
|
||||
imports: [RouterLink, Icon, Status],
|
||||
template: `
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Journal</h1>
|
||||
<p>Toutes les actions faites depuis le tableau de bord, et les connexions.</p>
|
||||
</div>
|
||||
<div class="segmented" role="group" aria-label="Filtrer par résultat">
|
||||
<button type="button" [class.active]="status() === null" (click)="setStatus(null)">
|
||||
Tout
|
||||
</button>
|
||||
<button type="button" [class.active]="status() === 'error'" (click)="setStatus('error')">
|
||||
Échecs
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
[class.active]="status() === 'running'"
|
||||
(click)="setStatus('running')"
|
||||
>
|
||||
En cours
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (error()) {
|
||||
<div class="alert critical" role="alert">
|
||||
<app-icon name="critical" />
|
||||
<p>{{ error() }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<section class="card">
|
||||
<div class="card-body flush table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Action</th>
|
||||
<th>Cible</th>
|
||||
<th>Par</th>
|
||||
<th class="num">Durée</th>
|
||||
<th class="actions"><span class="sr-only">Détails</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (entry of entries(); track entry.id) {
|
||||
<tr>
|
||||
<td class="nowrap">{{ dateTime(entry.createdAt) }}</td>
|
||||
<td><app-status [level]="statusOf(entry).level" [label]="entry.action" /></td>
|
||||
<td class="target mono ellipsis" [title]="entry.target ?? ''">
|
||||
{{ entry.target ?? '–' }}
|
||||
</td>
|
||||
<td class="nowrap">
|
||||
{{ entry.username ?? '–' }}
|
||||
<span class="sub">{{ entry.ip }}</span>
|
||||
</td>
|
||||
<td class="num">{{ duration(entry) }}</td>
|
||||
<td class="actions">
|
||||
@if (entry.hasOutput || entry.status === 'running') {
|
||||
<a class="btn small" [routerLink]="['/jobs', entry.id]">Sortie</a>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="6" class="empty">Aucune entrée</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (lastPage() > 1) {
|
||||
<nav class="pagination" aria-label="Pages">
|
||||
<button
|
||||
type="button"
|
||||
class="btn small"
|
||||
(click)="page.set(page() - 1)"
|
||||
[disabled]="page() <= 1"
|
||||
>
|
||||
Précédent
|
||||
</button>
|
||||
<span class="muted">Page {{ page() }} sur {{ lastPage() }} · {{ total() }} entrées</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn small"
|
||||
(click)="page.set(page() + 1)"
|
||||
[disabled]="page() >= lastPage()"
|
||||
>
|
||||
Suivant
|
||||
</button>
|
||||
</nav>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
.target {
|
||||
max-width: 280px;
|
||||
}
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class JournalPage {
|
||||
private api = inject(Api);
|
||||
|
||||
protected readonly entries = signal<AuditEntry[]>([]);
|
||||
protected readonly page = signal(1);
|
||||
protected readonly lastPage = signal(1);
|
||||
protected readonly total = signal(0);
|
||||
protected readonly status = signal<AuditStatus | null>(null);
|
||||
protected readonly error = signal<string | null>(null);
|
||||
|
||||
protected readonly dateTime = formatDateTime;
|
||||
protected readonly statusOf = (entry: AuditEntry) => STATUS[entry.status];
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const page = this.page();
|
||||
const status = this.status();
|
||||
untracked(() => this.load(page, status));
|
||||
});
|
||||
}
|
||||
|
||||
protected setStatus(status: AuditStatus | null) {
|
||||
this.status.set(status);
|
||||
this.page.set(1);
|
||||
}
|
||||
|
||||
protected duration(entry: AuditEntry) {
|
||||
if (!entry.finishedAt) return '–';
|
||||
const seconds =
|
||||
(new Date(entry.finishedAt).getTime() - new Date(entry.createdAt).getTime()) / 1000;
|
||||
return seconds < 1 ? '< 1 s' : formatDuration(seconds);
|
||||
}
|
||||
|
||||
private async load(page: number, status: AuditStatus | null) {
|
||||
try {
|
||||
const result = await this.api.audit(page, status ?? undefined);
|
||||
this.entries.set(result.data);
|
||||
this.lastPage.set(result.meta.lastPage);
|
||||
this.total.set(result.meta.total);
|
||||
this.error.set(null);
|
||||
} catch (error) {
|
||||
this.error.set(errorMessage(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { ChangeDetectionStrategy, Component, inject, input, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { Auth } from '../../core/auth';
|
||||
import { errorMessage } from '../../core/format';
|
||||
import { Icon } from '../../shared/icon';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
imports: [FormsModule, Icon],
|
||||
template: `
|
||||
<main class="login">
|
||||
<form class="card" (ngSubmit)="submit()" autocomplete="on">
|
||||
<div class="brand">
|
||||
<span class="logo"><app-icon name="eye" /></span>
|
||||
<div>
|
||||
<h1>Big Brother</h1>
|
||||
<p>Gestion du VPS</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (error()) {
|
||||
<div class="alert critical" role="alert">
|
||||
<app-icon name="critical" />
|
||||
<p>{{ error() }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="field">
|
||||
<label for="username">Identifiant</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
class="input"
|
||||
autocomplete="username"
|
||||
required
|
||||
[(ngModel)]="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="password">Mot de passe</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
class="input"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
[(ngModel)]="password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="code">Code de double authentification</label>
|
||||
<input
|
||||
id="code"
|
||||
name="code"
|
||||
class="input code"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
pattern="[0-9]{6}"
|
||||
maxlength="6"
|
||||
placeholder="123456"
|
||||
required
|
||||
[(ngModel)]="code"
|
||||
/>
|
||||
<span class="hint">Les 6 chiffres affichés par ton application (Aegis, 2FAS…).</span>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn primary" [disabled]="loading()">
|
||||
@if (loading()) {
|
||||
<app-icon name="loader" class="spin" />
|
||||
}
|
||||
Se connecter
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
`,
|
||||
styles: `
|
||||
.login {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 100vh;
|
||||
padding: 16px;
|
||||
}
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: min(380px, 100%);
|
||||
padding: 28px;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.brand p {
|
||||
margin: 2px 0 0;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.logo {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 10px;
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
.logo app-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
.code {
|
||||
font-family: var(--mono);
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.3em;
|
||||
}
|
||||
button[type='submit'] {
|
||||
height: 40px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class Login {
|
||||
private auth = inject(Auth);
|
||||
private router = inject(Router);
|
||||
|
||||
/**
|
||||
* Page asked before being sent to the login page.
|
||||
*/
|
||||
readonly next = input<string>();
|
||||
|
||||
protected readonly username = signal('');
|
||||
protected readonly password = signal('');
|
||||
protected readonly code = signal('');
|
||||
protected readonly loading = signal(false);
|
||||
protected readonly error = signal<string | null>(null);
|
||||
|
||||
protected async submit() {
|
||||
if (this.loading()) return;
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
try {
|
||||
await this.auth.login(this.username().trim(), this.password(), this.code().trim());
|
||||
const next = this.next();
|
||||
await this.router.navigateByUrl(
|
||||
next && next.startsWith('/') && !next.startsWith('//') ? next : '/',
|
||||
);
|
||||
} catch (error) {
|
||||
this.error.set(errorMessage(error));
|
||||
this.code.set('');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Vue d’ensemble</h1>
|
||||
<p>État du serveur, mis à jour toutes les 5 secondes.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (error()) {
|
||||
<div class="alert critical" role="alert">
|
||||
<app-icon name="critical" />
|
||||
<p>{{ error() }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (latest(); as host) {
|
||||
<section class="grid-tiles" aria-label="Ressources du serveur">
|
||||
<div class="card tile">
|
||||
<span class="tile-label">Processeur</span>
|
||||
<span class="tile-value">{{ format.percent(host.cpu) }}</span>
|
||||
<span class="tile-sub">{{ overview()?.host?.cpus ?? '–' }} cœurs</span>
|
||||
<div class="meter" [class]="meterLevel(host.cpu)">
|
||||
<span [style.width.%]="host.cpu"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card tile">
|
||||
<span class="tile-label">Mémoire</span>
|
||||
<span class="tile-value">{{
|
||||
format.percent(format.ratio(host.memUsed, host.memTotal))
|
||||
}}</span>
|
||||
<span class="tile-sub"
|
||||
>{{ format.bytes(host.memUsed) }} sur {{ format.bytes(host.memTotal) }}</span
|
||||
>
|
||||
<div class="meter" [class]="meterLevel(format.ratio(host.memUsed, host.memTotal))">
|
||||
<span [style.width.%]="format.ratio(host.memUsed, host.memTotal)"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card tile">
|
||||
<span class="tile-label">Disque</span>
|
||||
<span class="tile-value">{{
|
||||
format.percent(format.ratio(host.diskUsed, host.diskTotal))
|
||||
}}</span>
|
||||
<span class="tile-sub"
|
||||
>{{ format.bytes(host.diskTotal - host.diskUsed) }} libres sur
|
||||
{{ format.bytes(host.diskTotal) }}</span
|
||||
>
|
||||
<div class="meter" [class]="meterLevel(format.ratio(host.diskUsed, host.diskTotal))">
|
||||
<span [style.width.%]="format.ratio(host.diskUsed, host.diskTotal)"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card tile">
|
||||
<span class="tile-label">Réseau</span>
|
||||
<span class="tile-value small">↓ {{ format.rate(host.netRx) }}</span>
|
||||
<span class="tile-sub">↑ {{ format.rate(host.netTx) }} envoyés</span>
|
||||
</div>
|
||||
|
||||
<div class="card tile">
|
||||
<span class="tile-label">Charge (1 min)</span>
|
||||
<span class="tile-value">{{ format.number(host.load1, 2) }}</span>
|
||||
<span class="tile-sub">pour {{ overview()?.host?.cpus ?? '–' }} cœurs</span>
|
||||
</div>
|
||||
|
||||
<div class="card tile">
|
||||
<span class="tile-label">Allumé depuis</span>
|
||||
<span class="tile-value">{{ format.duration(overview()?.host?.uptime) }}</span>
|
||||
<span class="tile-sub">sans redémarrage</span>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (overview(); as data) {
|
||||
<section class="status-grid" aria-label="État des services">
|
||||
<a class="card status-card" routerLink="/containers">
|
||||
<div class="status-head">
|
||||
<h2>Conteneurs</h2>
|
||||
@if (!data.docker.ok) {
|
||||
<app-status level="critical" label="Docker injoignable" />
|
||||
} @else if (data.docker.unhealthy.length) {
|
||||
<app-status level="critical" [label]="data.docker.unhealthy.length + ' en erreur'" />
|
||||
} @else {
|
||||
<app-status level="good" label="OK" />
|
||||
}
|
||||
</div>
|
||||
@if (data.docker.ok) {
|
||||
<p class="big">
|
||||
{{ data.docker.running }} <span>/ {{ data.docker.total }} en marche</span>
|
||||
</p>
|
||||
@if (data.docker.unhealthy.length) {
|
||||
<p class="detail">En erreur : {{ data.docker.unhealthy.join(', ') }}</p>
|
||||
}
|
||||
@if (data.docker.stopped.length) {
|
||||
<p class="detail">Arrêtés : {{ data.docker.stopped.join(', ') }}</p>
|
||||
}
|
||||
} @else {
|
||||
<p class="detail">{{ data.docker.error }}</p>
|
||||
}
|
||||
</a>
|
||||
|
||||
<a class="card status-card" routerLink="/sites">
|
||||
<div class="status-head">
|
||||
<h2>Sites</h2>
|
||||
@if (data.sites.down.length) {
|
||||
<app-status level="critical" [label]="data.sites.down.length + ' hors ligne'" />
|
||||
} @else if (data.sites.certSoon.length) {
|
||||
<app-status level="warning" label="Certificat à renouveler" />
|
||||
} @else {
|
||||
<app-status level="good" label="Tous en ligne" />
|
||||
}
|
||||
</div>
|
||||
<p class="big">
|
||||
{{ data.sites.up }} <span>/ {{ data.sites.total }} en ligne</span>
|
||||
</p>
|
||||
@for (site of data.sites.down; track site.id) {
|
||||
<p class="detail">{{ site.domain }} : {{ site.error }}</p>
|
||||
}
|
||||
@for (site of data.sites.certSoon; track site.id) {
|
||||
<p class="detail">Certificat de {{ site.domain }} : expire dans {{ site.days }} j</p>
|
||||
}
|
||||
</a>
|
||||
|
||||
<a class="card status-card" routerLink="/backups">
|
||||
<div class="status-head">
|
||||
<h2>Sauvegardes</h2>
|
||||
@if (!data.backups.last) {
|
||||
<app-status level="warning" label="Aucune" />
|
||||
} @else {
|
||||
<app-status level="good" label="OK" />
|
||||
}
|
||||
</div>
|
||||
@if (data.backups.last; as last) {
|
||||
<p class="big small-text">{{ format.relative(last.createdAt) }}</p>
|
||||
<p class="detail">Dernière : {{ format.dateTime(last.createdAt) }}</p>
|
||||
} @else {
|
||||
<p class="detail">Aucune sauvegarde dans le dossier.</p>
|
||||
}
|
||||
<p class="detail">
|
||||
@if (data.backups.schedule.dailyAt) {
|
||||
Automatique chaque jour à {{ data.backups.schedule.dailyAt }}
|
||||
} @else {
|
||||
Pas de sauvegarde automatique
|
||||
}
|
||||
</p>
|
||||
</a>
|
||||
</section>
|
||||
}
|
||||
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h2>Historique</h2>
|
||||
<p>
|
||||
@if (range() === 'live') {
|
||||
10 dernières minutes, en direct
|
||||
} @else {
|
||||
Moyennes sur la période
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div class="segmented" role="group" aria-label="Période">
|
||||
@for (option of ranges; track option.value) {
|
||||
<button
|
||||
type="button"
|
||||
[class.active]="range() === option.value"
|
||||
(click)="range.set(option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body charts" [class.loading]="loadingHistory()">
|
||||
<div>
|
||||
<h3>Processeur et mémoire</h3>
|
||||
<app-line-chart
|
||||
[series]="cpuMemSeries()"
|
||||
[formatY]="formatPercent"
|
||||
[yMax]="100"
|
||||
[xMin]="xRange().min"
|
||||
[xMax]="xRange().max"
|
||||
label="Utilisation du processeur et de la mémoire en pourcentage"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div>
|
||||
<h3>Réseau</h3>
|
||||
@if (hasNetwork()) {
|
||||
<app-line-chart
|
||||
[series]="networkSeries()"
|
||||
[formatY]="formatRate"
|
||||
[ySuggestedMax]="1024"
|
||||
[xMin]="xRange().min"
|
||||
[xMax]="xRange().max"
|
||||
[height]="180"
|
||||
label="Débit réseau reçu et envoyé"
|
||||
/>
|
||||
} @else {
|
||||
<p class="muted">Débit réseau indisponible sur cette machine.</p>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<h3>Disque</h3>
|
||||
<app-line-chart
|
||||
[series]="diskSeries()"
|
||||
[formatY]="formatPercent"
|
||||
[yMax]="100"
|
||||
[xMin]="xRange().min"
|
||||
[xMax]="xRange().max"
|
||||
[height]="180"
|
||||
label="Espace disque utilisé en pourcentage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (overview()?.recent?.length) {
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<h2>Dernières actions</h2>
|
||||
<a routerLink="/journal">Tout le journal</a>
|
||||
</div>
|
||||
<div class="card-body flush table-wrap">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
@for (entry of overview()!.recent; track entry.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<app-status
|
||||
[level]="
|
||||
entry.status === 'success'
|
||||
? 'good'
|
||||
: entry.status === 'error'
|
||||
? 'critical'
|
||||
: 'running'
|
||||
"
|
||||
[label]="entry.action"
|
||||
/>
|
||||
</td>
|
||||
<td class="text-2 ellipsis">{{ entry.target }}</td>
|
||||
<td class="muted nowrap">{{ entry.username ?? '–' }}</td>
|
||||
<td class="muted nowrap num">{{ format.relative(entry.createdAt) }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,73 @@
|
||||
.tile-value.small {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 16px 18px;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
transition: border-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--border-strong);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.status-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.big {
|
||||
margin: 4px 0 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
|
||||
span {
|
||||
color: var(--text-2);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
&.small-text {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.detail {
|
||||
margin: 0;
|
||||
color: var(--text-2);
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.charts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&.loading {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
td.ellipsis {
|
||||
max-width: 320px;
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Api } from '../../core/api';
|
||||
import {
|
||||
errorMessage,
|
||||
formatBytes,
|
||||
formatDateTime,
|
||||
formatDuration,
|
||||
formatNumber,
|
||||
formatPercent,
|
||||
formatRate,
|
||||
formatRelative,
|
||||
percent,
|
||||
} from '../../core/format';
|
||||
import type { HistoryPoint, HistoryRange, HostSample, Overview } from '../../core/models';
|
||||
import { openStream } from '../../core/sse';
|
||||
import { Icon } from '../../shared/icon';
|
||||
import { LineChart, type ChartSeries } from '../../shared/line-chart';
|
||||
import { Status } from '../../shared/status';
|
||||
|
||||
type Range = 'live' | HistoryRange;
|
||||
|
||||
const RANGES: { value: Range; label: string }[] = [
|
||||
{ value: 'live', label: 'Direct' },
|
||||
{ value: '1h', label: '1 h' },
|
||||
{ value: '24h', label: '24 h' },
|
||||
{ value: '7d', label: '7 j' },
|
||||
{ value: '30d', label: '30 j' },
|
||||
];
|
||||
|
||||
const RANGE_MS: Record<HistoryRange, number> = {
|
||||
'1h': 3600_000,
|
||||
'24h': 86400_000,
|
||||
'7d': 7 * 86400_000,
|
||||
'30d': 30 * 86400_000,
|
||||
};
|
||||
|
||||
interface Point {
|
||||
x: number;
|
||||
cpu: number | null;
|
||||
mem: number | null;
|
||||
disk: number | null;
|
||||
rx: number | null;
|
||||
tx: number | null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-overview',
|
||||
imports: [RouterLink, Icon, LineChart, Status],
|
||||
templateUrl: './overview.html',
|
||||
styleUrl: './overview.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class OverviewPage {
|
||||
private api = inject(Api);
|
||||
|
||||
protected readonly ranges = RANGES;
|
||||
protected readonly overview = signal<Overview | null>(null);
|
||||
protected readonly error = signal<string | null>(null);
|
||||
protected readonly live = signal<HostSample[]>([]);
|
||||
protected readonly range = signal<Range>('live');
|
||||
protected readonly history = signal<HistoryPoint[]>([]);
|
||||
protected readonly loadingHistory = signal(false);
|
||||
private readonly now = signal(Date.now());
|
||||
|
||||
protected readonly latest = computed(
|
||||
() => this.live().at(-1) ?? this.overview()?.host.latest ?? null,
|
||||
);
|
||||
|
||||
protected readonly points = computed<Point[]>(() => {
|
||||
if (this.range() === 'live') {
|
||||
return this.live().map((sample) => ({
|
||||
x: sample.ts,
|
||||
cpu: sample.cpu,
|
||||
mem: percent(sample.memUsed, sample.memTotal),
|
||||
disk: percent(sample.diskUsed, sample.diskTotal),
|
||||
rx: sample.netRx,
|
||||
tx: sample.netTx,
|
||||
}));
|
||||
}
|
||||
return this.history().map((point) => ({
|
||||
x: point.t * 1000,
|
||||
cpu: point.cpu,
|
||||
mem: percent(point.memUsed, point.memTotal),
|
||||
disk: percent(point.diskUsed, point.diskTotal),
|
||||
rx: point.netRx,
|
||||
tx: point.netTx,
|
||||
}));
|
||||
});
|
||||
|
||||
protected readonly xRange = computed(() => {
|
||||
const range = this.range();
|
||||
if (range === 'live') return { min: null, max: null };
|
||||
const now = this.now();
|
||||
return { min: now - RANGE_MS[range], max: now };
|
||||
});
|
||||
|
||||
protected readonly cpuMemSeries = computed<ChartSeries[]>(() => [
|
||||
{
|
||||
label: 'Processeur',
|
||||
color: 'series-1',
|
||||
data: this.points().map((p) => ({ x: p.x, y: p.cpu })),
|
||||
},
|
||||
{ label: 'Mémoire', color: 'series-2', data: this.points().map((p) => ({ x: p.x, y: p.mem })) },
|
||||
]);
|
||||
|
||||
protected readonly networkSeries = computed<ChartSeries[]>(() => [
|
||||
{ label: 'Reçu', color: 'series-1', data: this.points().map((p) => ({ x: p.x, y: p.rx })) },
|
||||
{ label: 'Envoyé', color: 'series-2', data: this.points().map((p) => ({ x: p.x, y: p.tx })) },
|
||||
]);
|
||||
|
||||
protected readonly diskSeries = computed<ChartSeries[]>(() => [
|
||||
{
|
||||
label: 'Disque utilisé',
|
||||
color: 'series-1',
|
||||
fill: true,
|
||||
data: this.points().map((p) => ({ x: p.x, y: p.disk })),
|
||||
},
|
||||
]);
|
||||
|
||||
protected readonly hasNetwork = computed(
|
||||
() => this.latest()?.netRx !== null || this.range() !== 'live',
|
||||
);
|
||||
|
||||
protected readonly formatPercent = (value: number) => formatPercent(value);
|
||||
protected readonly formatRate = (value: number) => formatRate(value);
|
||||
protected readonly format = {
|
||||
bytes: formatBytes,
|
||||
rate: formatRate,
|
||||
percent: formatPercent,
|
||||
duration: formatDuration,
|
||||
number: formatNumber,
|
||||
relative: formatRelative,
|
||||
dateTime: formatDateTime,
|
||||
ratio: percent,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
const destroyRef = inject(DestroyRef);
|
||||
|
||||
this.refresh();
|
||||
const timer = setInterval(() => {
|
||||
this.refresh();
|
||||
const range = this.range();
|
||||
if (range !== 'live') {
|
||||
this.now.set(Date.now());
|
||||
this.loadHistory(range);
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
const close = openStream('/api/metrics/live', {
|
||||
init: (samples: HostSample[]) => this.live.set(samples),
|
||||
sample: (sample: HostSample) =>
|
||||
this.live.update((samples) => [...samples.slice(-119), sample]),
|
||||
});
|
||||
|
||||
destroyRef.onDestroy(() => {
|
||||
clearInterval(timer);
|
||||
close();
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const range = this.range();
|
||||
if (range !== 'live') {
|
||||
this.now.set(Date.now());
|
||||
this.loadHistory(range);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected meterLevel(value: number) {
|
||||
if (value >= 90) return 'critical';
|
||||
if (value >= 80) return 'warning';
|
||||
return '';
|
||||
}
|
||||
|
||||
private async refresh() {
|
||||
try {
|
||||
this.overview.set(await this.api.overview());
|
||||
this.error.set(null);
|
||||
} catch (error) {
|
||||
this.error.set(errorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
private async loadHistory(range: HistoryRange) {
|
||||
this.loadingHistory.set(true);
|
||||
try {
|
||||
const result = await this.api.history(range);
|
||||
if (this.range() === range) this.history.set(result.points);
|
||||
} catch (error) {
|
||||
this.error.set(errorMessage(error));
|
||||
} finally {
|
||||
this.loadingHistory.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<a routerLink="/projects" class="back-link"><app-icon name="back" /> Projets</a>
|
||||
<h1>{{ name() }}</h1>
|
||||
<p>
|
||||
{{ categoryLabel() }}
|
||||
@if (project(); as p) {
|
||||
· <span class="mono">{{ p.path }}</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
@if (status(); as s) {
|
||||
<app-status [level]="s.level" [label]="s.label" />
|
||||
}
|
||||
</header>
|
||||
|
||||
@if (error()) {
|
||||
<div class="alert critical" role="alert">
|
||||
<app-icon name="critical" />
|
||||
<p>{{ error() }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (jobId(); as id) {
|
||||
<app-job-console [jobId]="id" (finished)="load()" />
|
||||
}
|
||||
|
||||
@if (project(); as p) {
|
||||
@if (p.kind !== 'static') {
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h2>Actions</h2>
|
||||
<p>Chaque action est enregistrée dans le journal avec sa sortie.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body actions-grid">
|
||||
@if (p.git) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn primary"
|
||||
(click)="act('deploy')"
|
||||
[disabled]="busy() || p.git.detached"
|
||||
>
|
||||
<app-icon name="download" /> Mettre à jour
|
||||
</button>
|
||||
<button type="button" class="btn" (click)="fetch()" [disabled]="fetching()">
|
||||
<app-icon name="refresh" [class.spin]="fetching()" /> Vérifier les nouveautés
|
||||
</button>
|
||||
}
|
||||
<button type="button" class="btn" (click)="act('restart')" [disabled]="busy()">
|
||||
<app-icon name="restart" /> Redémarrer
|
||||
</button>
|
||||
<button type="button" class="btn" (click)="act('recreate')" [disabled]="busy()">
|
||||
<app-icon name="refresh" /> Recréer
|
||||
</button>
|
||||
<button type="button" class="btn" (click)="act('pull')" [disabled]="busy()">
|
||||
<app-icon name="upload" /> Mettre à jour les images
|
||||
</button>
|
||||
<button type="button" class="btn" (click)="act('start')" [disabled]="busy()">
|
||||
<app-icon name="play" /> Démarrer
|
||||
</button>
|
||||
@if (!p.self) {
|
||||
<button type="button" class="btn danger" (click)="act('stop')" [disabled]="busy()">
|
||||
<app-icon name="stop" /> Arrêter
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (p.git?.detached) {
|
||||
<div class="alert warning">
|
||||
<app-icon name="alert" />
|
||||
<p>
|
||||
Le projet tourne sur un ancien commit ({{ p.git?.commit?.short }}). La mise à jour est
|
||||
bloquée tant qu’il n’est pas revenu sur la branche.
|
||||
<button
|
||||
type="button"
|
||||
class="btn small"
|
||||
(click)="act('checkout-branch')"
|
||||
[disabled]="busy()"
|
||||
>
|
||||
Revenir sur la branche {{ p.defaultBranch }}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (p.git?.dirty) {
|
||||
<div class="alert warning">
|
||||
<app-icon name="alert" />
|
||||
<p>
|
||||
{{ p.git?.dirty }} fichier(s) suivi(s) par git ont été modifiés directement sur le
|
||||
serveur. Le <code>git pull</code> peut échouer : modifie le code sur ton PC et pousse-le
|
||||
plutôt.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<section class="grid-2">
|
||||
<div class="card">
|
||||
<div class="card-header"><h2>Conteneurs</h2></div>
|
||||
<div class="card-body flush table-wrap">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
@for (container of p.containers; track container.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<a [routerLink]="['/containers', container.name]">{{ container.name }}</a>
|
||||
</td>
|
||||
<td>
|
||||
<app-status
|
||||
[level]="containerStatus(container.state, container.health).level"
|
||||
[label]="containerStatus(container.state, container.health).label"
|
||||
/>
|
||||
</td>
|
||||
<td class="muted">{{ container.status }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td class="empty">
|
||||
@if (p.kind === 'static') {
|
||||
Servi directement par Caddy, sans conteneur.
|
||||
} @else {
|
||||
Aucun conteneur pour ce projet (jamais lancé ou supprimé).
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (p.git; as git) {
|
||||
<div class="card">
|
||||
<div class="card-header"><h2>Dépôt git</h2></div>
|
||||
<div class="card-body">
|
||||
<dl class="kv">
|
||||
<dt>Dépôt</dt>
|
||||
<dd class="mono">{{ p.remote ?? '–' }}</dd>
|
||||
<dt>Branche</dt>
|
||||
<dd>{{ git.detached ? 'aucune (ancien commit)' : git.branch }}</dd>
|
||||
<dt>Version en ligne</dt>
|
||||
<dd>
|
||||
@if (git.commit) {
|
||||
<span class="mono">{{ git.commit.short }}</span> · {{ git.commit.subject }}
|
||||
<span class="sub muted"
|
||||
>{{ git.commit.author }}, {{ relative(git.commit.date) }}</span
|
||||
>
|
||||
}
|
||||
</dd>
|
||||
<dt>Nouveautés</dt>
|
||||
<dd>
|
||||
@if (git.behind) {
|
||||
<app-status level="warning" [label]="git.behind + ' commit(s) à récupérer'" />
|
||||
} @else {
|
||||
À jour (depuis la dernière vérification)
|
||||
}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
} @else if (p.kind === 'static') {
|
||||
<div class="card">
|
||||
<div class="card-header"><h2>Mettre à jour le site</h2></div>
|
||||
<div class="card-body stack">
|
||||
<p class="text-2">
|
||||
Dernière mise à jour des fichiers : {{ dateTime(p.updatedAt) }}. Le build se fait sur
|
||||
ton PC, puis on envoie le dossier (voir la procédure) :
|
||||
</p>
|
||||
<pre class="code-block">{{ staticDeploy() }}</pre>
|
||||
<div>
|
||||
<button type="button" class="btn small" (click)="copy(staticDeploy())">Copier</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="card">
|
||||
<div class="card-header"><h2>Dépôt git</h2></div>
|
||||
<div class="card-body text-2">
|
||||
Pas de dépôt git dans <span class="mono">project/</span> : la mise à jour par git n’est
|
||||
pas disponible.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (p.commits.length) {
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h2>Historique</h2>
|
||||
<p>Revenir à un commit précédent si une mise à jour a cassé quelque chose.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body flush table-wrap">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
@for (commit of p.commits; track commit.hash) {
|
||||
<tr [class.current]="isCurrent(commit)">
|
||||
<td class="mono nowrap">{{ commit.short }}</td>
|
||||
<td>
|
||||
{{ commit.subject }}
|
||||
<span class="sub">{{ commit.author }} · {{ dateTime(commit.date) }}</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
@if (isCurrent(commit)) {
|
||||
<span class="pill">en ligne</span>
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
class="btn small"
|
||||
(click)="act('checkout', commit)"
|
||||
[disabled]="busy()"
|
||||
>
|
||||
<app-icon name="history" /> Revenir à ce commit
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (p.compose) {
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<h2>{{ p.composeFile }}</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<pre class="code-block">{{ p.compose }}</pre>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,18 @@
|
||||
.actions-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.alert p .btn {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
tr.current td {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.sub {
|
||||
display: block;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Api } from '../../core/api';
|
||||
import { Feedback } from '../../core/feedback';
|
||||
import { errorMessage, formatDateTime, formatRelative } from '../../core/format';
|
||||
import type { GitCommit, ProjectAction, ProjectDetails } from '../../core/models';
|
||||
import { Icon } from '../../shared/icon';
|
||||
import { JobConsole } from '../../shared/job-console';
|
||||
import { Status, containerStatus } from '../../shared/status';
|
||||
import { CATEGORY_LABELS, confirmProjectAction, projectStatus } from './project-helpers';
|
||||
|
||||
@Component({
|
||||
selector: 'app-project-detail',
|
||||
imports: [RouterLink, Icon, JobConsole, Status],
|
||||
templateUrl: './project-detail.html',
|
||||
styleUrl: './project-detail.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ProjectDetailPage {
|
||||
private api = inject(Api);
|
||||
private feedback = inject(Feedback);
|
||||
|
||||
readonly category = input.required<string>();
|
||||
readonly name = input.required<string>();
|
||||
|
||||
protected readonly project = signal<ProjectDetails | null>(null);
|
||||
protected readonly error = signal<string | null>(null);
|
||||
protected readonly busy = signal(false);
|
||||
protected readonly fetching = signal(false);
|
||||
protected readonly jobId = signal<number | null>(null);
|
||||
|
||||
protected readonly status = computed(() => {
|
||||
const project = this.project();
|
||||
return project ? projectStatus(project) : null;
|
||||
});
|
||||
|
||||
protected readonly categoryLabel = computed(
|
||||
() => CATEGORY_LABELS[this.category()] ?? this.category(),
|
||||
);
|
||||
|
||||
/**
|
||||
* Commands of the procedure to send the build of a static site.
|
||||
*/
|
||||
protected readonly staticDeploy = computed(() => {
|
||||
const project = this.project();
|
||||
if (!project || project.kind !== 'static') return '';
|
||||
const target = project.path.replace(/\\/g, '/');
|
||||
return [
|
||||
'# sur le PC, dans le dossier du projet',
|
||||
'npm run build',
|
||||
`ssh <user>@<vps> "rm -rf ${target}.new"`,
|
||||
`scp -r dist <user>@<vps>:${target}.new`,
|
||||
`ssh <user>@<vps> "rm -rf ${target} && mv ${target}.new ${target}"`,
|
||||
].join('\n');
|
||||
});
|
||||
|
||||
protected readonly containerStatus = containerStatus;
|
||||
protected readonly dateTime = formatDateTime;
|
||||
protected readonly relative = formatRelative;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const category = this.category();
|
||||
const name = this.name();
|
||||
untracked(() => this.load(category, name));
|
||||
});
|
||||
}
|
||||
|
||||
protected async load(category = this.category(), name = this.name()) {
|
||||
try {
|
||||
this.project.set(await this.api.project(category, name));
|
||||
this.error.set(null);
|
||||
} catch (error) {
|
||||
this.error.set(errorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
protected async fetch() {
|
||||
this.fetching.set(true);
|
||||
try {
|
||||
const git = await this.api.fetchProject(this.category(), this.name());
|
||||
this.feedback.info(
|
||||
git.behind ? `${git.behind} nouveau(x) commit(s) disponible(s).` : 'Le projet est à jour.',
|
||||
);
|
||||
await this.load();
|
||||
} catch (error) {
|
||||
this.feedback.error(errorMessage(error));
|
||||
} finally {
|
||||
this.fetching.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected async act(action: ProjectAction, commit?: GitCommit) {
|
||||
const project = this.project();
|
||||
if (!project) return;
|
||||
const extra = commit ? `\n\n${commit.short} · ${commit.subject}` : '';
|
||||
if (!(await confirmProjectAction(this.feedback, project, action, extra))) return;
|
||||
|
||||
this.busy.set(true);
|
||||
try {
|
||||
const { jobId } = await this.api.projectAction(
|
||||
project.category,
|
||||
project.name,
|
||||
action,
|
||||
commit?.hash,
|
||||
);
|
||||
this.jobId.set(jobId);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
} catch (error) {
|
||||
this.feedback.error(errorMessage(error));
|
||||
} finally {
|
||||
this.busy.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected isCurrent(commit: GitCommit) {
|
||||
return this.project()?.git?.commit?.hash === commit.hash;
|
||||
}
|
||||
|
||||
protected copy(text: string) {
|
||||
navigator.clipboard?.writeText(text).then(
|
||||
() => this.feedback.success('Copié.'),
|
||||
() => this.feedback.error('Copie impossible.'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { Feedback } from '../../core/feedback';
|
||||
import type { Project, ProjectAction } from '../../core/models';
|
||||
import type { StatusLevel } from '../../shared/status';
|
||||
|
||||
export const CATEGORY_LABELS: Record<string, string> = {
|
||||
api: 'API',
|
||||
frontend: 'Frontend',
|
||||
fullstack: 'Fullstack',
|
||||
wordpress: 'WordPress',
|
||||
html: 'Sites statiques',
|
||||
infra: 'Infrastructure',
|
||||
};
|
||||
|
||||
export const CATEGORY_ORDER = ['api', 'fullstack', 'frontend', 'wordpress', 'html', 'infra'];
|
||||
|
||||
export function projectStatus(project: Project): { level: StatusLevel; label: string } {
|
||||
if (project.kind === 'static') return { level: 'neutral', label: 'Fichiers statiques' };
|
||||
const total = project.containers.length;
|
||||
const running = project.containers.filter((container) => container.state === 'running').length;
|
||||
const unhealthy = project.containers.some((container) => container.health === 'unhealthy');
|
||||
if (total === 0) return { level: 'neutral', label: 'Aucun conteneur' };
|
||||
if (unhealthy) return { level: 'critical', label: 'En erreur' };
|
||||
if (running === total)
|
||||
return { level: 'good', label: total > 1 ? `En marche (${total})` : 'En marche' };
|
||||
if (running === 0) return { level: 'neutral', label: 'Arrêté' };
|
||||
return { level: 'warning', label: `${running}/${total} en marche` };
|
||||
}
|
||||
|
||||
const CONFIRMATIONS: Record<
|
||||
ProjectAction,
|
||||
{ title: string; message: string; label: string; danger?: boolean }
|
||||
> = {
|
||||
deploy: {
|
||||
title: 'Mettre à jour {name} ?',
|
||||
message:
|
||||
'git pull, puis les conteneurs sont recréés (le site est coupé pendant le redémarrage).',
|
||||
label: 'Mettre à jour',
|
||||
},
|
||||
restart: {
|
||||
title: 'Redémarrer {name} ?',
|
||||
message: 'Le service sera coupé quelques secondes.',
|
||||
label: 'Redémarrer',
|
||||
},
|
||||
stop: {
|
||||
title: 'Arrêter {name} ?',
|
||||
message: 'Le site restera hors ligne jusqu’à ce que tu le relances.',
|
||||
label: 'Arrêter',
|
||||
danger: true,
|
||||
},
|
||||
start: { title: 'Démarrer {name} ?', message: 'docker compose up -d', label: 'Démarrer' },
|
||||
recreate: {
|
||||
title: 'Recréer les conteneurs de {name} ?',
|
||||
message:
|
||||
'Relit le docker-compose.yml et le .env, reconstruit si besoin. Le service est coupé pendant l’opération.',
|
||||
label: 'Recréer',
|
||||
},
|
||||
pull: {
|
||||
title: 'Mettre à jour les images de {name} ?',
|
||||
message: 'docker compose pull puis redémarrage avec les nouvelles images.',
|
||||
label: 'Mettre à jour les images',
|
||||
},
|
||||
checkout: {
|
||||
title: 'Revenir à ce commit ?',
|
||||
message:
|
||||
'Le projet passe sur l’ancienne version puis redémarre. « Revenir sur la branche » annule ce retour.',
|
||||
label: 'Revenir à ce commit',
|
||||
danger: true,
|
||||
},
|
||||
'checkout-branch': {
|
||||
title: 'Revenir sur la branche ?',
|
||||
message: 'Le projet repasse sur la dernière version de la branche, puis redémarre.',
|
||||
label: 'Revenir sur la branche',
|
||||
},
|
||||
};
|
||||
|
||||
export function confirmProjectAction(
|
||||
feedback: Feedback,
|
||||
project: Project,
|
||||
action: ProjectAction,
|
||||
extra = '',
|
||||
) {
|
||||
const config = CONFIRMATIONS[action];
|
||||
const critical =
|
||||
project.category === 'infra' && ['stop', 'restart', 'recreate', 'pull'].includes(action);
|
||||
return feedback.confirm({
|
||||
title: config.title.replace('{name}', project.name),
|
||||
message:
|
||||
config.message +
|
||||
extra +
|
||||
(critical
|
||||
? '\n\nAttention : c’est un service d’infrastructure, d’autres projets en dépendent.'
|
||||
: '') +
|
||||
(project.self
|
||||
? '\n\nC’est le tableau de bord lui-même : la page sera indisponible quelques instants.'
|
||||
: ''),
|
||||
confirmLabel: config.label,
|
||||
danger: config.danger || critical,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<a routerLink="/projects" class="back-link"><app-icon name="back" /> Projets</a>
|
||||
<h1>Nouveau projet</h1>
|
||||
<p>Les mêmes étapes que la procédure, faites automatiquement.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (jobId(); as id) {
|
||||
<app-job-console [jobId]="id" (finished)="jobStatus.set($event)" />
|
||||
@if (jobStatus() === 'success') {
|
||||
<div class="alert info">
|
||||
<app-icon name="good" />
|
||||
<p>
|
||||
Projet créé.
|
||||
<a [routerLink]="projectLink()">Ouvrir la page du projet</a>
|
||||
@if (addCaddy()) {
|
||||
·
|
||||
<a [href]="'https://' + domain()" target="_blank" rel="noopener noreferrer"
|
||||
>https://{{ domain() }}</a
|
||||
>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
@if (jobStatus() === 'error') {
|
||||
<div class="alert critical">
|
||||
<app-icon name="critical" />
|
||||
<p>
|
||||
La création s’est arrêtée à l’étape en erreur (voir ci-dessus). Les étapes déjà faites ne
|
||||
sont pas annulées : corrige le problème sur le serveur ou supprime le dossier
|
||||
{{ targetPath() }} avant de réessayer.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<form class="layout" (ngSubmit)="submit()">
|
||||
<div class="stack">
|
||||
<section class="card">
|
||||
<div class="card-header"><h2>Type de projet</h2></div>
|
||||
<div class="card-body type-grid" role="radiogroup" aria-label="Type de projet">
|
||||
@for (option of types; track option.value) {
|
||||
<button
|
||||
type="button"
|
||||
class="type-option"
|
||||
role="radio"
|
||||
[attr.aria-checked]="type() === option.value"
|
||||
[class.selected]="type() === option.value"
|
||||
(click)="selectType(option.value)"
|
||||
>
|
||||
<strong>{{ option.label }}</strong>
|
||||
<span>{{ option.description }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-header"><h2>Projet</h2></div>
|
||||
<div class="card-body stack">
|
||||
<div class="field">
|
||||
<label for="name">Nom</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
class="input"
|
||||
[(ngModel)]="name"
|
||||
placeholder="mon-projet"
|
||||
required
|
||||
autocomplete="off"
|
||||
/>
|
||||
<span class="hint"
|
||||
>Minuscules, chiffres et tirets. Sert de nom de dossier, de conteneur et de
|
||||
sous-domaine.</span
|
||||
>
|
||||
@if (name() && !nameValid()) {
|
||||
<span class="field-error">Nom invalide.</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (isApp()) {
|
||||
<div class="field">
|
||||
<label for="category">Dossier</label>
|
||||
<select id="category" name="category" class="select" [(ngModel)]="category">
|
||||
@for (option of options()?.categories ?? []; track option) {
|
||||
@if (option !== 'wordpress') {
|
||||
<option [value]="option">/srv/{{ option }}</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="git">URL du dépôt git</label>
|
||||
<input
|
||||
id="git"
|
||||
name="git"
|
||||
class="input mono"
|
||||
[(ngModel)]="gitUrl"
|
||||
placeholder="https://git.nucleus-ludus.fr/compte/mon-projet.git"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<span class="hint"
|
||||
>Dépôt privé : utilise un jeton en lecture seule dans l’URL
|
||||
(https://compte:jeton@…).</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label for="port">Port interne</label>
|
||||
<input
|
||||
id="port"
|
||||
name="port"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
class="input"
|
||||
[(ngModel)]="port"
|
||||
/>
|
||||
<span class="hint">Port écouté par l’app dans le conteneur (pas publié).</span>
|
||||
</div>
|
||||
@if (type() === 'node') {
|
||||
<div class="field grow">
|
||||
<label for="command">Commande de démarrage</label>
|
||||
<input id="command" name="command" class="input mono" [(ngModel)]="command" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="env">Contenu du .env</label>
|
||||
<textarea
|
||||
id="env"
|
||||
name="env"
|
||||
class="textarea"
|
||||
[(ngModel)]="env"
|
||||
rows="8"
|
||||
placeholder="APP_KEY=... SESSION_DRIVER=cookie"
|
||||
></textarea>
|
||||
<span class="hint">
|
||||
Copie ton .env de production (sans NODE_ENV, HOST ni PORT : le compose les fixe).
|
||||
@if (type() === 'adonis') {
|
||||
APP_KEY, LOG_LEVEL et APP_URL sont ajoutés s’ils manquent.
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="db" [(ngModel)]="createDatabase" />
|
||||
<span>
|
||||
Créer une base MariaDB pour ce projet
|
||||
<small
|
||||
>Base et utilisateur dédiés ; DB_HOST, DB_USER, DB_PASSWORD… sont ajoutés au
|
||||
.env.</small
|
||||
>
|
||||
</span>
|
||||
</label>
|
||||
}
|
||||
|
||||
@if (type() === 'wordpress') {
|
||||
<div class="alert info">
|
||||
<app-icon name="warning" />
|
||||
<p>
|
||||
Une base et un utilisateur dédiés sont créés automatiquement, le mot de passe va
|
||||
dans le .env du projet.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-header"><h2>Accès web</h2></div>
|
||||
<div class="card-body stack">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="caddy" [(ngModel)]="addCaddy" />
|
||||
<span>
|
||||
Ajouter au Caddyfile (HTTPS automatique)
|
||||
<small
|
||||
>Le domaine doit pointer sur le VPS (DNS) pour que le certificat soit
|
||||
délivré.</small
|
||||
>
|
||||
</span>
|
||||
</label>
|
||||
@if (addCaddy()) {
|
||||
<div class="field">
|
||||
<label for="domain">Domaine</label>
|
||||
<input
|
||||
id="domain"
|
||||
name="domain"
|
||||
class="input"
|
||||
[ngModel]="domain()"
|
||||
(ngModelChange)="customDomain.set($event)"
|
||||
/>
|
||||
</div>
|
||||
@if (type() === 'static') {
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="spa" [(ngModel)]="spa" />
|
||||
<span>
|
||||
Application monopage (Vue, React, Angular…)
|
||||
<small
|
||||
>Les URL inconnues renvoient index.html. À décocher pour un site multi-pages
|
||||
classique.</small
|
||||
>
|
||||
</span>
|
||||
</label>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside class="card summary">
|
||||
<div class="card-header"><h2>Ce qui va être fait</h2></div>
|
||||
<div class="card-body stack">
|
||||
<ol>
|
||||
@for (step of steps(); track $index) {
|
||||
<li>{{ step }}</li>
|
||||
}
|
||||
</ol>
|
||||
@if (error()) {
|
||||
<div class="alert critical" role="alert">
|
||||
<app-icon name="critical" />
|
||||
<p>{{ error() }}</p>
|
||||
</div>
|
||||
}
|
||||
<button type="submit" class="btn primary" [disabled]="submitting() || !options()">
|
||||
@if (submitting()) {
|
||||
<app-icon name="loader" class="spin" />
|
||||
}
|
||||
Créer le projet
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,83 @@
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
align-items: start;
|
||||
gap: 16px;
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.type-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.type-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
span {
|
||||
color: var(--text-2);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
box-shadow: inset 0 0 0 1px var(--accent);
|
||||
}
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
|
||||
.field {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.field-error {
|
||||
color: var(--critical-text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.summary {
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
|
||||
ol {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
color: var(--text-2);
|
||||
|
||||
li + li {
|
||||
margin-top: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
button[type='submit'] {
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Api } from '../../core/api';
|
||||
import { errorMessage } from '../../core/format';
|
||||
import type {
|
||||
AuditStatus,
|
||||
CreateProjectInput,
|
||||
ProjectOptions,
|
||||
ProjectType,
|
||||
} from '../../core/models';
|
||||
import { Icon } from '../../shared/icon';
|
||||
import { JobConsole } from '../../shared/job-console';
|
||||
|
||||
const TYPES: { value: ProjectType; label: string; description: string }[] = [
|
||||
{
|
||||
value: 'node',
|
||||
label: 'Node.js',
|
||||
description: 'Une app Node lancée par une commande (node start.js…).',
|
||||
},
|
||||
{
|
||||
value: 'adonis',
|
||||
label: 'AdonisJS',
|
||||
description: 'Compilée (node ace build) puis lancée en production.',
|
||||
},
|
||||
{ value: 'wordpress', label: 'WordPress', description: 'Image officielle, base MariaDB dédiée.' },
|
||||
{
|
||||
value: 'static',
|
||||
label: 'Site statique',
|
||||
description: 'Fichiers servis directement par Caddy.',
|
||||
},
|
||||
];
|
||||
|
||||
const NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,40}$/;
|
||||
|
||||
@Component({
|
||||
selector: 'app-project-new',
|
||||
imports: [FormsModule, RouterLink, Icon, JobConsole],
|
||||
templateUrl: './project-new.html',
|
||||
styleUrl: './project-new.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ProjectNewPage {
|
||||
private api = inject(Api);
|
||||
|
||||
protected readonly types = TYPES;
|
||||
protected readonly options = signal<ProjectOptions | null>(null);
|
||||
|
||||
protected readonly type = signal<ProjectType>('node');
|
||||
protected readonly name = signal('');
|
||||
protected readonly category = signal('api');
|
||||
protected readonly gitUrl = signal('');
|
||||
protected readonly port = signal(3333);
|
||||
protected readonly command = signal('');
|
||||
protected readonly env = signal('');
|
||||
protected readonly createDatabase = signal(false);
|
||||
protected readonly addCaddy = signal(true);
|
||||
protected readonly spa = signal(true);
|
||||
/**
|
||||
* Null while the domain follows the name.
|
||||
*/
|
||||
protected readonly customDomain = signal<string | null>(null);
|
||||
|
||||
protected readonly submitting = signal(false);
|
||||
protected readonly error = signal<string | null>(null);
|
||||
protected readonly jobId = signal<number | null>(null);
|
||||
protected readonly jobStatus = signal<AuditStatus | null>(null);
|
||||
|
||||
protected readonly domain = computed(
|
||||
() =>
|
||||
this.customDomain() ??
|
||||
(this.name() ? `${this.name()}.${this.options()?.baseDomain ?? ''}` : ''),
|
||||
);
|
||||
|
||||
protected readonly isApp = computed(() => this.type() === 'node' || this.type() === 'adonis');
|
||||
protected readonly nameValid = computed(() => NAME_PATTERN.test(this.name()));
|
||||
|
||||
protected readonly targetPath = computed(() => {
|
||||
const root = this.options()?.srvRoot ?? '/srv';
|
||||
const folder =
|
||||
this.type() === 'static'
|
||||
? 'html'
|
||||
: this.type() === 'wordpress'
|
||||
? 'wordpress'
|
||||
: this.category();
|
||||
return `${root}/${folder}/${this.name() || '<nom>'}`.replace(/\\/g, '/');
|
||||
});
|
||||
|
||||
/**
|
||||
* What the creation will do, step by step.
|
||||
*/
|
||||
protected readonly steps = computed(() => {
|
||||
const steps: string[] = [`Créer le dossier ${this.targetPath()}`];
|
||||
const type = this.type();
|
||||
if (type === 'static') {
|
||||
steps.push('Y mettre une page d’attente (à remplacer par ton build)');
|
||||
} else {
|
||||
if (this.isApp()) steps.push('Cloner le dépôt dans project/');
|
||||
if (type === 'wordpress' || this.createDatabase()) {
|
||||
steps.push(
|
||||
`Créer la base MariaDB « ${(this.name() || '<nom>').replace(/-/g, '_')} » et son utilisateur`,
|
||||
);
|
||||
}
|
||||
steps.push(
|
||||
type === 'wordpress' ? 'Écrire le .env (identifiants de la base)' : 'Écrire project/.env',
|
||||
);
|
||||
steps.push('Écrire le docker-compose.yml (template de la procédure)');
|
||||
steps.push('Lancer : docker compose up -d');
|
||||
}
|
||||
if (this.addCaddy())
|
||||
steps.push(`Ajouter ${this.domain() || '<domaine>'} au Caddyfile et recharger Caddy`);
|
||||
return steps;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.api
|
||||
.projectOptions()
|
||||
.then((options) => {
|
||||
this.options.set(options);
|
||||
this.command.set(options.defaultNodeCommand);
|
||||
})
|
||||
.catch((error) => this.error.set(errorMessage(error)));
|
||||
}
|
||||
|
||||
protected selectType(type: ProjectType) {
|
||||
this.type.set(type);
|
||||
if (type === 'wordpress') this.category.set('wordpress');
|
||||
else if (this.category() === 'wordpress') this.category.set('api');
|
||||
}
|
||||
|
||||
protected async submit() {
|
||||
this.error.set(null);
|
||||
if (!this.nameValid()) {
|
||||
this.error.set('Le nom ne peut contenir que des minuscules, des chiffres et des tirets.');
|
||||
return;
|
||||
}
|
||||
if (this.isApp() && !this.gitUrl().trim()) {
|
||||
this.error.set('L’URL du dépôt git est obligatoire.');
|
||||
return;
|
||||
}
|
||||
|
||||
const type = this.type();
|
||||
const input: CreateProjectInput = {
|
||||
type,
|
||||
name: this.name(),
|
||||
category: type === 'wordpress' ? 'wordpress' : this.category(),
|
||||
createDatabase: type === 'wordpress' || (this.isApp() && this.createDatabase()),
|
||||
domain: this.domain(),
|
||||
addCaddy: this.addCaddy(),
|
||||
spa: this.spa(),
|
||||
};
|
||||
if (this.isApp()) {
|
||||
input.gitUrl = this.gitUrl().trim();
|
||||
input.port = Number(this.port());
|
||||
input.env = this.env();
|
||||
if (type === 'node') input.command = this.command().trim();
|
||||
}
|
||||
|
||||
this.submitting.set(true);
|
||||
try {
|
||||
const { jobId } = await this.api.createProject(input);
|
||||
this.jobStatus.set(null);
|
||||
this.jobId.set(jobId);
|
||||
} catch (error) {
|
||||
this.error.set(errorMessage(error));
|
||||
} finally {
|
||||
this.submitting.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected projectLink() {
|
||||
const type = this.type();
|
||||
const category =
|
||||
type === 'static' ? 'html' : type === 'wordpress' ? 'wordpress' : this.category();
|
||||
return ['/projects', category, this.name()];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Api } from '../../core/api';
|
||||
import { Feedback } from '../../core/feedback';
|
||||
import { errorMessage, formatDateTime, formatRelative } from '../../core/format';
|
||||
import type { Project, ProjectAction } from '../../core/models';
|
||||
import { Icon } from '../../shared/icon';
|
||||
import { JobConsole } from '../../shared/job-console';
|
||||
import { Status } from '../../shared/status';
|
||||
import {
|
||||
CATEGORY_LABELS,
|
||||
CATEGORY_ORDER,
|
||||
confirmProjectAction,
|
||||
projectStatus,
|
||||
} from './project-helpers';
|
||||
|
||||
@Component({
|
||||
selector: 'app-projects',
|
||||
imports: [RouterLink, Icon, JobConsole, Status],
|
||||
template: `
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Projets</h1>
|
||||
<p>
|
||||
Les projets de <span class="mono">/srv</span>, avec leur dépôt git et leurs conteneurs.
|
||||
</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<button type="button" class="btn" (click)="load()">
|
||||
<app-icon name="refresh" [class.spin]="loading()" /> Rafraîchir
|
||||
</button>
|
||||
<a routerLink="/projects/new" class="btn primary"
|
||||
><app-icon name="plus" /> Nouveau projet</a
|
||||
>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (error()) {
|
||||
<div class="alert critical" role="alert">
|
||||
<app-icon name="critical" />
|
||||
<p>{{ error() }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (jobId(); as id) {
|
||||
<div class="stack">
|
||||
<app-job-console [jobId]="id" (finished)="load()" />
|
||||
<div>
|
||||
<button type="button" class="btn small ghost" (click)="jobId.set(null)">Masquer</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@for (group of groups(); track group.category) {
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<h2>{{ group.label }}</h2>
|
||||
<span class="muted">{{ group.projects.length }}</span>
|
||||
</div>
|
||||
<div class="card-body flush table-wrap">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
@for (project of group.projects; track project.path) {
|
||||
<tr>
|
||||
<td class="name">
|
||||
<a [routerLink]="['/projects', project.category, project.name]">{{
|
||||
project.name
|
||||
}}</a>
|
||||
@if (project.self) {
|
||||
<span class="pill">ce tableau de bord</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<app-status [level]="status(project).level" [label]="status(project).label" />
|
||||
</td>
|
||||
<td class="git">
|
||||
@if (project.git; as git) {
|
||||
<span class="branch">
|
||||
<app-icon name="branch" />
|
||||
{{ git.detached ? 'commit ' + (git.commit?.short ?? '') : git.branch }}
|
||||
</span>
|
||||
@if (git.commit) {
|
||||
<span class="sub ellipsis" [title]="git.commit.subject">
|
||||
{{ git.commit.short }} · {{ git.commit.subject }} ·
|
||||
{{ relative(git.commit.date) }}
|
||||
</span>
|
||||
}
|
||||
@if (git.detached) {
|
||||
<app-status level="warning" label="Sur un ancien commit" />
|
||||
} @else if (git.behind) {
|
||||
<app-status
|
||||
level="warning"
|
||||
[label]="git.behind + ' commit(s) à récupérer'"
|
||||
/>
|
||||
}
|
||||
@if (git.dirty) {
|
||||
<app-status
|
||||
level="warning"
|
||||
[label]="git.dirty + ' fichier(s) modifié(s) sur le serveur'"
|
||||
/>
|
||||
}
|
||||
} @else if (project.kind === 'static') {
|
||||
<span class="sub">Mis à jour le {{ dateTime(project.updatedAt) }}</span>
|
||||
} @else {
|
||||
<span class="sub">Pas de dépôt git</span>
|
||||
}
|
||||
</td>
|
||||
<td class="actions">
|
||||
@if (project.git && !project.git.detached) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn small"
|
||||
(click)="act(project, 'deploy')"
|
||||
[disabled]="busy()"
|
||||
>
|
||||
<app-icon name="download" /> Mettre à jour
|
||||
</button>
|
||||
}
|
||||
@if (project.kind !== 'static') {
|
||||
<button
|
||||
type="button"
|
||||
class="btn small icon-only"
|
||||
(click)="act(project, 'restart')"
|
||||
[disabled]="busy()"
|
||||
[attr.aria-label]="'Redémarrer ' + project.name"
|
||||
>
|
||||
<app-icon name="restart" />
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
} @empty {
|
||||
<div class="card empty">
|
||||
{{ loading() ? 'Chargement…' : 'Aucun projet trouvé dans /srv' }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
.name {
|
||||
width: 22%;
|
||||
font-weight: 500;
|
||||
}
|
||||
.name .pill {
|
||||
margin-left: 6px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.git {
|
||||
max-width: 420px;
|
||||
}
|
||||
.git app-status {
|
||||
margin-right: 10px;
|
||||
}
|
||||
.branch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.branch app-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ProjectsPage {
|
||||
private api = inject(Api);
|
||||
private feedback = inject(Feedback);
|
||||
|
||||
protected readonly projects = signal<Project[]>([]);
|
||||
protected readonly loading = signal(true);
|
||||
protected readonly busy = signal(false);
|
||||
protected readonly error = signal<string | null>(null);
|
||||
protected readonly jobId = signal<number | null>(null);
|
||||
|
||||
protected readonly groups = computed(() => {
|
||||
const byCategory = new Map<string, Project[]>();
|
||||
for (const project of this.projects()) {
|
||||
byCategory.set(project.category, [...(byCategory.get(project.category) ?? []), project]);
|
||||
}
|
||||
const rank = (category: string) => {
|
||||
const index = CATEGORY_ORDER.indexOf(category);
|
||||
return index === -1 ? CATEGORY_ORDER.length - 1 : index;
|
||||
};
|
||||
return [...byCategory.entries()]
|
||||
.sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b))
|
||||
.map(([category, projects]) => ({
|
||||
category,
|
||||
label: CATEGORY_LABELS[category] ?? category,
|
||||
projects,
|
||||
}));
|
||||
});
|
||||
|
||||
protected readonly status = projectStatus;
|
||||
protected readonly relative = formatRelative;
|
||||
protected readonly dateTime = formatDateTime;
|
||||
|
||||
constructor() {
|
||||
this.load();
|
||||
}
|
||||
|
||||
async load() {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
this.projects.set(await this.api.projects());
|
||||
this.error.set(null);
|
||||
} catch (error) {
|
||||
this.error.set(errorMessage(error));
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected async act(project: Project, action: ProjectAction) {
|
||||
if (!(await confirmProjectAction(this.feedback, project, action))) return;
|
||||
this.busy.set(true);
|
||||
try {
|
||||
const { jobId } = await this.api.projectAction(project.category, project.name, action);
|
||||
this.jobId.set(jobId);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
} catch (error) {
|
||||
this.feedback.error(errorMessage(error));
|
||||
} finally {
|
||||
this.busy.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Api } from '../../core/api';
|
||||
import {
|
||||
errorMessage,
|
||||
formatDateTime,
|
||||
formatDuration,
|
||||
formatNumber,
|
||||
formatPercent,
|
||||
} from '../../core/format';
|
||||
import type { SiteDetails, SiteRange } from '../../core/models';
|
||||
import { Icon } from '../../shared/icon';
|
||||
import { LineChart, type ChartSeries } from '../../shared/line-chart';
|
||||
import { Status } from '../../shared/status';
|
||||
import { certificateStatus, siteStatus, uptimeLevel } from './site-status';
|
||||
|
||||
const RANGES: { value: SiteRange; label: string; ms: number }[] = [
|
||||
{ value: '24h', label: '24 h', ms: 86400_000 },
|
||||
{ value: '7d', label: '7 j', ms: 7 * 86400_000 },
|
||||
{ value: '30d', label: '30 j', ms: 30 * 86400_000 },
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-site-detail',
|
||||
imports: [RouterLink, Icon, LineChart, Status],
|
||||
template: `
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<a routerLink="/sites" class="back-link"><app-icon name="back" /> Sites</a>
|
||||
<h1>{{ details()?.site?.domain ?? 'Site' }}</h1>
|
||||
@if (details(); as d) {
|
||||
<p>
|
||||
@if (d.site.kind === 'proxy') {
|
||||
Reverse proxy vers <span class="mono">{{ d.site.upstream }}</span>
|
||||
} @else if (d.site.kind === 'static') {
|
||||
Fichiers statiques de <span class="mono">{{ d.site.upstream }}</span>
|
||||
}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div class="segmented" role="group" aria-label="Période">
|
||||
@for (option of ranges; track option.value) {
|
||||
<button
|
||||
type="button"
|
||||
[class.active]="range() === option.value"
|
||||
(click)="range.set(option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (error()) {
|
||||
<div class="alert critical" role="alert">
|
||||
<app-icon name="critical" />
|
||||
<p>{{ error() }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (details(); as d) {
|
||||
<section class="grid-tiles">
|
||||
<div class="card tile">
|
||||
<span class="tile-label">État actuel</span>
|
||||
<app-status [level]="status(d).level" [label]="status(d).label" />
|
||||
<span class="tile-sub">{{
|
||||
d.site.lastError ?? 'Vérifié le ' + dateTime(d.site.lastCheckedAt)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="card tile">
|
||||
<span class="tile-label">Disponibilité</span>
|
||||
<span class="tile-value">{{ percent(d.uptime) }}</span>
|
||||
<app-status [level]="uptimeLevel(d.uptime)" [label]="'sur ' + rangeLabel()" />
|
||||
</div>
|
||||
<div class="card tile">
|
||||
<span class="tile-label">Temps de réponse moyen</span>
|
||||
<span class="tile-value">{{
|
||||
d.avgResponseMs !== null ? number(d.avgResponseMs, 0) + ' ms' : '–'
|
||||
}}</span>
|
||||
<span class="tile-sub">sur {{ rangeLabel() }}</span>
|
||||
</div>
|
||||
<div class="card tile">
|
||||
<span class="tile-label">Certificat HTTPS</span>
|
||||
<app-status [level]="cert(d).level" [label]="cert(d).label" />
|
||||
<span class="tile-sub">Expire le {{ dateTime(d.site.certExpiresAt) }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h2>Temps de réponse</h2>
|
||||
<p>Moyenne par tranche de {{ bucketLabel() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body" [class.loading]="loading()">
|
||||
<app-line-chart
|
||||
[series]="series()"
|
||||
[formatY]="formatMs"
|
||||
[ySuggestedMax]="200"
|
||||
[xMin]="xRange().min"
|
||||
[xMax]="xRange().max"
|
||||
[height]="240"
|
||||
label="Temps de réponse du site en millisecondes, avec les vérifications en échec"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-header"><h2>Incidents</h2></div>
|
||||
<div class="card-body flush table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Début</th>
|
||||
<th>Fin</th>
|
||||
<th class="num">Durée</th>
|
||||
<th>Erreur</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (incident of d.incidents; track incident.start) {
|
||||
<tr>
|
||||
<td class="nowrap">{{ dateTime(incident.start) }}</td>
|
||||
<td class="nowrap">
|
||||
@if (incident.end) {
|
||||
{{ dateTime(incident.end) }}
|
||||
} @else {
|
||||
<app-status level="critical" label="En cours" />
|
||||
}
|
||||
</td>
|
||||
<td class="num">
|
||||
{{ duration((incident.end ?? now() / 1000) - incident.start) }}
|
||||
</td>
|
||||
<td class="text-2">{{ incident.error }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="4" class="empty">Aucun incident sur {{ rangeLabel() }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
.card-body.loading {
|
||||
opacity: 0.55;
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SiteDetailPage {
|
||||
private api = inject(Api);
|
||||
|
||||
readonly id = input.required<string>();
|
||||
|
||||
protected readonly ranges = RANGES;
|
||||
protected readonly range = signal<SiteRange>('24h');
|
||||
protected readonly details = signal<SiteDetails | null>(null);
|
||||
protected readonly loading = signal(false);
|
||||
protected readonly error = signal<string | null>(null);
|
||||
protected readonly now = signal(Date.now());
|
||||
|
||||
protected readonly rangeLabel = computed(
|
||||
() => RANGES.find((r) => r.value === this.range())!.label,
|
||||
);
|
||||
protected readonly bucketLabel = computed(() => formatDuration(this.details()?.bucket ?? 300));
|
||||
|
||||
protected readonly xRange = computed(() => {
|
||||
const ms = RANGES.find((r) => r.value === this.range())!.ms;
|
||||
return { min: this.now() - ms, max: this.now() };
|
||||
});
|
||||
|
||||
protected readonly series = computed<ChartSeries[]>(() => {
|
||||
const points = this.details()?.points ?? [];
|
||||
return [
|
||||
{
|
||||
label: 'Temps de réponse',
|
||||
color: 'series-1',
|
||||
data: points.map((p) => ({ x: p.t * 1000, y: p.responseMs })),
|
||||
},
|
||||
{
|
||||
label: 'Vérification en échec',
|
||||
color: 'critical',
|
||||
markers: true,
|
||||
data: points.map((p) => ({ x: p.t * 1000, y: p.ok ? null : 0 })),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
protected readonly status = (d: SiteDetails) => siteStatus(d.site);
|
||||
protected readonly cert = (d: SiteDetails) => certificateStatus(d.site.certExpiresAt);
|
||||
protected readonly uptimeLevel = uptimeLevel;
|
||||
protected readonly percent = (value: number | null) => formatPercent(value, 2);
|
||||
protected readonly number = formatNumber;
|
||||
protected readonly dateTime = formatDateTime;
|
||||
protected readonly duration = formatDuration;
|
||||
protected readonly formatMs = (value: number) => `${formatNumber(value, 0)} ms`;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const id = Number(this.id());
|
||||
const range = this.range();
|
||||
untracked(() => this.load(id, range));
|
||||
});
|
||||
}
|
||||
|
||||
private async load(id: number, range: SiteRange) {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const details = await this.api.site(id, range);
|
||||
this.now.set(Date.now());
|
||||
this.details.set(details);
|
||||
this.error.set(null);
|
||||
} catch (error) {
|
||||
this.error.set(errorMessage(error));
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { StatusLevel } from '../../shared/status';
|
||||
import type { Site } from '../../core/models';
|
||||
|
||||
export function siteStatus(site: Site): { level: StatusLevel; label: string } {
|
||||
if (!site.active) return { level: 'neutral', label: 'Retiré du Caddyfile' };
|
||||
if (site.lastOk === null) return { level: 'neutral', label: 'Pas encore vérifié' };
|
||||
if (site.lastOk)
|
||||
return { level: 'good', label: site.lastStatus ? `En ligne (${site.lastStatus})` : 'En ligne' };
|
||||
return { level: 'critical', label: 'Hors ligne' };
|
||||
}
|
||||
|
||||
export function certificateStatus(expiresAt: number | null): { level: StatusLevel; label: string } {
|
||||
if (!expiresAt) return { level: 'neutral', label: 'Inconnu' };
|
||||
const days = Math.floor((expiresAt * 1000 - Date.now()) / 86400_000);
|
||||
if (days < 0) return { level: 'critical', label: 'Expiré' };
|
||||
if (days < 7) return { level: 'critical', label: `${days} j restants` };
|
||||
if (days < 14) return { level: 'warning', label: `${days} j restants` };
|
||||
return { level: 'good', label: `${days} j restants` };
|
||||
}
|
||||
|
||||
export function uptimeLevel(uptime: number | null): StatusLevel {
|
||||
if (uptime === null) return 'neutral';
|
||||
if (uptime >= 99.5) return 'good';
|
||||
if (uptime >= 95) return 'warning';
|
||||
return 'critical';
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Api } from '../../core/api';
|
||||
import { Feedback } from '../../core/feedback';
|
||||
import { errorMessage, formatNumber, formatPercent, formatRelative } from '../../core/format';
|
||||
import type { SiteSummary } from '../../core/models';
|
||||
import { Icon } from '../../shared/icon';
|
||||
import { Status } from '../../shared/status';
|
||||
import { certificateStatus, siteStatus, uptimeLevel } from './site-status';
|
||||
|
||||
@Component({
|
||||
selector: 'app-sites',
|
||||
imports: [RouterLink, Icon, Status],
|
||||
template: `
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Sites</h1>
|
||||
<p>Les domaines du Caddyfile, vérifiés chaque minute depuis le serveur.</p>
|
||||
</div>
|
||||
<button type="button" class="btn" (click)="checkNow()" [disabled]="checking()">
|
||||
<app-icon name="refresh" [class.spin]="checking()" /> Vérifier maintenant
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@if (error()) {
|
||||
<div class="alert critical" role="alert">
|
||||
<app-icon name="critical" />
|
||||
<p>{{ error() }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="alert info">
|
||||
<app-icon name="warning" />
|
||||
<p>
|
||||
Les vérifications partent du VPS lui-même : elles détectent un conteneur arrêté ou une
|
||||
erreur de configuration, pas une panne complète du serveur.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-body flush table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Domaine</th>
|
||||
<th>État</th>
|
||||
<th class="num">Réponse</th>
|
||||
<th class="num">Dispo 24 h</th>
|
||||
<th class="num">Dispo 7 j</th>
|
||||
<th>Certificat HTTPS</th>
|
||||
<th class="num">Vérifié</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (site of activeSites(); track site.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<a [routerLink]="['/sites', site.id]">{{ site.domain }}</a>
|
||||
<a
|
||||
class="external"
|
||||
[href]="'https://' + site.domain"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
[attr.aria-label]="'Ouvrir ' + site.domain"
|
||||
>
|
||||
<app-icon name="external" />
|
||||
</a>
|
||||
<span class="sub">
|
||||
@if (site.kind === 'proxy') {
|
||||
→ conteneur <span class="mono">{{ site.upstream }}</span>
|
||||
} @else if (site.kind === 'static') {
|
||||
→ fichiers <span class="mono">{{ site.upstream ?? '' }}</span>
|
||||
} @else {
|
||||
autre configuration
|
||||
}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<app-status [level]="status(site).level" [label]="status(site).label" />
|
||||
@if (site.lastOk === false && site.lastError) {
|
||||
<span class="sub">{{ site.lastError }}</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num">
|
||||
{{
|
||||
site.lastResponseMs !== null ? number(site.lastResponseMs, 0) + ' ms' : '–'
|
||||
}}
|
||||
</td>
|
||||
<td class="num">
|
||||
<app-status
|
||||
[level]="uptimeLevel(site.uptime24h)"
|
||||
[label]="percent(site.uptime24h)"
|
||||
/>
|
||||
</td>
|
||||
<td class="num">
|
||||
<app-status
|
||||
[level]="uptimeLevel(site.uptime7d)"
|
||||
[label]="percent(site.uptime7d)"
|
||||
/>
|
||||
</td>
|
||||
<td><app-status [level]="cert(site).level" [label]="cert(site).label" /></td>
|
||||
<td class="num muted">{{ relative(site.lastCheckedAt) }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="7" class="empty">
|
||||
{{ loading() ? 'Chargement…' : 'Aucun domaine trouvé dans le Caddyfile' }}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (removedSites().length) {
|
||||
<details class="card removed">
|
||||
<summary>
|
||||
Retirés du Caddyfile ({{ removedSites().length }}) : historique conservé 30 jours
|
||||
</summary>
|
||||
<ul>
|
||||
@for (site of removedSites(); track site.id) {
|
||||
<li>
|
||||
<a [routerLink]="['/sites', site.id]">{{ site.domain }}</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</details>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
.external {
|
||||
display: inline-flex;
|
||||
margin-left: 6px;
|
||||
color: var(--muted);
|
||||
vertical-align: -3px;
|
||||
}
|
||||
.external app-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
td.num app-status {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.removed summary {
|
||||
padding: 12px 18px;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.removed ul {
|
||||
margin: 0;
|
||||
padding: 0 18px 14px 36px;
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SitesPage {
|
||||
private api = inject(Api);
|
||||
private feedback = inject(Feedback);
|
||||
|
||||
protected readonly sites = signal<SiteSummary[]>([]);
|
||||
protected readonly loading = signal(true);
|
||||
protected readonly checking = signal(false);
|
||||
protected readonly error = signal<string | null>(null);
|
||||
|
||||
protected readonly activeSites = computed(() => this.sites().filter((site) => site.active));
|
||||
protected readonly removedSites = computed(() => this.sites().filter((site) => !site.active));
|
||||
|
||||
protected readonly status = siteStatus;
|
||||
protected readonly cert = (site: SiteSummary) => certificateStatus(site.certExpiresAt);
|
||||
protected readonly uptimeLevel = uptimeLevel;
|
||||
protected readonly percent = (value: number | null) => formatPercent(value, 1);
|
||||
protected readonly number = formatNumber;
|
||||
protected readonly relative = formatRelative;
|
||||
|
||||
constructor() {
|
||||
this.load();
|
||||
const timer = setInterval(() => this.load(), 30_000);
|
||||
inject(DestroyRef).onDestroy(() => clearInterval(timer));
|
||||
}
|
||||
|
||||
private async load() {
|
||||
try {
|
||||
this.sites.set(await this.api.sites());
|
||||
this.error.set(null);
|
||||
} catch (error) {
|
||||
this.error.set(errorMessage(error));
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected async checkNow() {
|
||||
this.checking.set(true);
|
||||
try {
|
||||
this.sites.set(await this.api.checkSites());
|
||||
this.feedback.success('Vérification terminée.');
|
||||
} catch (error) {
|
||||
this.feedback.error(errorMessage(error));
|
||||
} finally {
|
||||
this.checking.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user