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

@@ -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);
}
}
}