first commit

This commit is contained in:
Rutra committed 2026-10-12 14:25:22 +02:00
commit 3c0479faf0
169 files changed
+27617

No files matched your search

+182
View File
@@ -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));
}
}
}
+173
View File
@@ -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>
+71
View File
@@ -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;
}
+127
View File
@@ -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;
});
}
}
}
+281
View File
@@ -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);
}
}
}
+28
View File
@@ -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()));
}
+178
View File
@@ -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));
}
}
}
+161
View File
@@ -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);
}
}
}
+252
View File
@@ -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>
+73
View File
@@ -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;
}
+205
View File
@@ -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,
});
}
+239
View File
@@ -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&#64;…).</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=...&#10;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;
}
}
+177
View File
@@ -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()];
}
}
+236
View File
@@ -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);
}
}
}
+235
View File
@@ -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);
}
}
}
+26
View File
@@ -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';
}
+213
View File
@@ -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);
}
}
}