first commit
This commit is contained in:
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user