Édition du docker-compose.yml depuis la page d'un conteneur

Vérification par docker compose config avant écriture, application avec
docker compose up -d et retour à la version précédente en cas d'échec.
Historique des versions par fichier. Le Caddyfile et le compose gardent
un retour à la ligne final.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
RutraandClaude Opus 5.5 committed 2026-10-12 18:31:33 +02:00
1 parent dcdac915f9
commit c106fcfc62
27 files changed
+1035 -58

No files matched your search

+1 -54
View File
@@ -1,43 +1,4 @@
.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 {
.site-list {
margin: 0;
padding: 0;
list-style: none;
@@ -55,17 +16,3 @@
}
}
}
.version-list li {
gap: 8px;
.muted {
display: block;
font-size: 12.5px;
}
}
.version-actions {
display: flex;
gap: 6px;
}
+1 -1
View File
@@ -11,7 +11,7 @@ import { Icon } from '../../shared/icon';
selector: 'app-caddy',
imports: [FormsModule, Icon],
templateUrl: './caddy.html',
styleUrl: './caddy.scss',
styleUrls: ['../../shared/editor-page.scss', './caddy.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CaddyPage {
@@ -0,0 +1,193 @@
<div class="page">
<header class="page-header">
<div>
<a [routerLink]="['/containers', id()]" class="back-link"
><app-icon name="back" /> {{ id() }}</a
>
<h1>{{ fileName() }}</h1>
@if (info(); as i) {
<p>
Projet compose <strong>{{ i.project }}</strong> ·
<span class="mono">{{ i.file }}</span>
</p>
}
</div>
</header>
@if (error()) {
<div class="alert critical" role="alert">
<app-icon name="critical" />
<p>{{ error() }}</p>
</div>
}
@if (jobId(); as job) {
<app-job-console [jobId]="job" (finished)="onFinished($event)" />
}
@if (info(); as i) {
@if (!i.editable) {
<div class="alert info">
<app-icon name="lock" />
<p>{{ i.readOnlyReason }}</p>
</div>
}
<div class="layout">
<section class="card editor-card">
<div class="card-header">
<div>
<h2>{{ fileName() }}</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"
[attr.aria-label]="'Contenu de ' + fileName()"
[readOnly]="!i.editable"
[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 ? 'warning' : 'critical'" />
<div>
<p>
{{
out.ok
? 'docker compose a accepté le fichier, avec ces remarques :'
: 'docker compose a refusé le fichier : rien n’a été modifié.'
}}
</p>
<pre class="output">{{ out.text }}</pre>
</div>
</div>
}
@if (i.editable) {
<div class="save-row">
<input
class="input"
placeholder="Motif (facultatif), ex. : port retiré"
[ngModel]="reason()"
(ngModelChange)="reason.set($event)"
aria-label="Motif de la modification"
/>
<button type="button" class="btn" (click)="reset()" [disabled]="!dirty() || busy()">
Annuler
</button>
<button
type="button"
class="btn"
(click)="save(false)"
[disabled]="!dirty() || busy()"
>
<app-icon name="save" /> Enregistrer
</button>
<button type="button" class="btn primary" (click)="save(true)" [disabled]="busy()">
@if (busy()) {
<app-icon name="loader" class="spin" />
} @else {
<app-icon name="play" />
}
{{ dirty() ? 'Enregistrer et appliquer' : 'Appliquer' }}
</button>
</div>
<p class="hint">
Le fichier est toujours vérifié par <span class="mono">docker compose config</span>
avant d’être écrit. « Enregistrer » ne touche pas aux conteneurs : les changements
s’appliquent au prochain démarrage du projet. « Appliquer » lance
<span class="mono">docker compose up -d</span> tout de suite.
</p>
}
</div>
</section>
<aside class="stack">
<section class="card">
<div class="card-header">
<div>
<h2>Conteneurs du projet</h2>
<p>Ceux que l’application peut recréer</p>
</div>
</div>
<div class="card-body flush">
<ul class="container-list">
@for (name of i.containers; track name) {
<li>
<a [routerLink]="['/containers', name]">{{ name }}</a>
@if (name === id()) {
<span class="pill">ce conteneur</span>
}
</li>
} @empty {
<li class="muted">Aucun conteneur en cours</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 i.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>
</div>
</li>
} @empty {
<li class="muted">
Pas encore de version (elles apparaissent après la première modification depuis le
tableau de bord).
</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">
@if (i.editable) {
<button
type="button"
class="btn small"
(click)="loadVersion(version)"
[disabled]="busy()"
>
Charger dans l’éditeur
</button>
}
<button type="button" class="btn small ghost" (click)="viewed.set(null)">Fermer</button>
</div>
</div>
<div class="card-body">
<pre class="code-block">{{ version.content }}</pre>
</div>
</section>
}
}
</div>
@@ -0,0 +1,25 @@
.hint {
margin: 0;
color: var(--muted);
font-size: 12.5px;
}
.container-list {
margin: 0;
padding: 0;
list-style: none;
li {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 10px 18px;
border-bottom: 1px solid var(--border);
overflow-wrap: anywhere;
&:last-child {
border-bottom: 0;
}
}
}
@@ -0,0 +1,178 @@
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
input,
signal,
untracked,
} from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { Api } from '../../core/api';
import { Feedback } from '../../core/feedback';
import { errorMessage, formatDateTime } from '../../core/format';
import type { AuditStatus, ComposeInfo, ComposeVersion } from '../../core/models';
import { Icon } from '../../shared/icon';
import { JobConsole } from '../../shared/job-console';
/**
* The textarea gives back "\n" line breaks: the file is compared the same way.
*/
function normalize(content: string) {
return content.replace(/\r\n/g, '\n');
}
@Component({
selector: 'app-container-compose',
imports: [FormsModule, RouterLink, Icon, JobConsole],
templateUrl: './container-compose.html',
styleUrls: ['../../shared/editor-page.scss', './container-compose.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ContainerComposePage {
private api = inject(Api);
private feedback = inject(Feedback);
/**
* Container name or id (route parameter).
*/
readonly id = input.required<string>();
protected readonly info = signal<ComposeInfo | null>(null);
protected readonly content = signal('');
protected readonly reason = signal('');
protected readonly busy = signal(false);
protected readonly error = signal<string | null>(null);
protected readonly output = signal<{ ok: boolean; text: string } | null>(null);
protected readonly viewed = signal<ComposeVersion | null>(null);
protected readonly jobId = signal<number | null>(null);
protected readonly dirty = computed(() => {
const info = this.info();
return info !== null && this.content() !== normalize(info.content);
});
protected readonly lineCount = computed(() => this.content().split('\n').length);
protected readonly fileName = computed(
() => this.info()?.file.split(/[\\/]/).pop() ?? 'docker-compose.yml',
);
protected readonly dateTime = formatDateTime;
constructor() {
effect(() => {
const id = this.id();
untracked(() => this.load(id));
});
}
/**
* keepContent: after a failed apply, the edited text stays in the editor.
*/
protected async load(id = this.id(), keepContent = false) {
try {
const info = await this.api.compose(id);
this.info.set(info);
if (!keepContent) this.content.set(normalize(info.content));
this.error.set(null);
} catch (error) {
this.error.set(errorMessage(error));
}
}
protected async save(apply: boolean) {
const info = this.info();
if (!info) return;
if (apply) {
const others = info.containers.length > 1 ? ` (${info.containers.join(', ')})` : '';
const ok = await this.feedback.confirm({
title: this.dirty() ? 'Enregistrer et appliquer ?' : 'Appliquer le fichier ?',
message: `« docker compose up -d » recrée les conteneurs du projet ${info.project}${others} dont la configuration a changé : ils sont coupés quelques secondes. Si la commande échoue, la version précédente est remise en place et relancée.`,
confirmLabel: this.dirty() ? 'Enregistrer et appliquer' : 'Appliquer',
});
if (!ok) return;
}
this.busy.set(true);
this.output.set(null);
this.jobId.set(null);
try {
const result = await this.api.saveCompose(
this.id(),
this.content(),
this.reason().trim(),
apply,
);
this.reason.set('');
if (result.output && result.output !== 'Aucun changement.') {
this.output.set({ ok: true, text: result.output });
}
if (result.jobId !== null) {
/**
* The page stays busy until the job console reports the end.
*/
this.jobId.set(result.jobId);
return;
}
this.feedback.success(
result.changed ? `${this.fileName()} enregistré.` : 'Aucun changement.',
);
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 {
if (this.jobId() === null) this.busy.set(false);
}
}
protected async onFinished(status: AuditStatus) {
this.busy.set(false);
if (status === 'success') {
this.feedback.success(`${this.fileName()} appliqué.`);
await this.load();
} else {
this.feedback.error('L’application a échoué : le détail est dans la console.');
await this.load(this.id(), true);
}
}
protected reset() {
this.content.set(normalize(this.info()?.content ?? ''));
this.output.set(null);
}
protected async view(version: ComposeVersion) {
try {
this.viewed.set(await this.api.composeVersion(this.id(), version.id));
} catch (error) {
this.feedback.error(errorMessage(error));
}
}
protected loadVersion(version: ComposeVersion) {
this.content.set(normalize(version.content ?? ''));
this.viewed.set(null);
this.output.set(null);
this.feedback.info(
`Version #${version.id} chargée dans l’éditeur : enregistre pour la remettre.`,
);
}
/**
* Tab inserts two spaces: YAML does not accept tabs for indentation.
*/
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)} ${value.slice(selectionEnd)}`;
this.content.set(next);
textarea.value = next;
textarea.selectionStart = textarea.selectionEnd = selectionStart + 2;
}
}
@@ -9,6 +9,11 @@
</div>
@if (container(); as c) {
<div class="toolbar">
@if (c.composeProject) {
<a class="btn" [routerLink]="['/containers', c.name, 'compose']">
<app-icon name="file" /> docker-compose.yml
</a>
}
@if (c.state === 'running') {
<button type="button" class="btn" (click)="act('restart')" [disabled]="busy()">
<app-icon name="restart" /> Redémarrer
@@ -232,6 +232,11 @@
<section class="card">
<div class="card-header">
<h2>{{ p.composeFile }}</h2>
@if (p.containers.length) {
<a class="btn small" [routerLink]="['/containers', p.containers[0].name, 'compose']">
<app-icon name="file" /> Modifier
</a>
}
</div>
<div class="card-body">
<pre class="code-block">{{ p.compose }}</pre>