É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 -1
View File
@@ -12,7 +12,7 @@ Tableau de bord web pour gérer et surveiller le VPS : serveur, conteneurs, site
| Page | Contenu | | Page | Contenu |
|---|---| |---|---|
| Vue d'ensemble | Processeur, mémoire, disque, réseau, charge, durée depuis le démarrage. Graphiques en direct (toutes les 5 s) et historique sur 1 h, 24 h, 7 j et 30 j. État des conteneurs, des sites et des sauvegardes, dernières actions. | | Vue d'ensemble | Processeur, mémoire, disque, réseau, charge, durée depuis le démarrage. Graphiques en direct (toutes les 5 s) et historique sur 1 h, 24 h, 7 j et 30 j. État des conteneurs, des sites et des sauvegardes, dernières actions. |
| Conteneurs | Tous les conteneurs avec leur état, CPU et mémoire. Alerte quand un port est publié sur Internet (seul Caddy devrait l'être). Logs en direct (filtre, pause, téléchargement). Démarrer, arrêter, redémarrer. | | Conteneurs | Tous les conteneurs avec leur état, CPU et mémoire. Alerte quand un port est publié sur Internet (seul Caddy devrait l'être). Logs en direct (filtre, pause, téléchargement). Démarrer, arrêter, redémarrer. **Édition du `docker-compose.yml`** du conteneur : le fichier est vérifié par `docker compose config` avant d'être écrit, puis appliqué avec `docker compose up -d` si demandé. Si `up` échoue, l'ancienne version est remise en place et relancée. Historique des 30 dernières versions de chaque fichier. Le compose du tableau de bord lui-même reste en lecture seule. |
| Sites | Les domaines du Caddyfile, vérifiés chaque minute : état, temps de réponse, disponibilité sur 24 h et 7 j, expiration du certificat HTTPS, incidents. | | Sites | Les domaines du Caddyfile, vérifiés chaque minute : état, temps de réponse, disponibilité sur 24 h et 7 j, expiration du certificat HTTPS, incidents. |
| Projets | Les projets de `/srv` (même organisation que la procédure). Pour chacun : dépôt git (version en ligne, nouveautés), mise à jour (`git pull` puis recréation des conteneurs), retour à un ancien commit, redémarrage, arrêt, mise à jour des images. **Création de projet** Node, Adonis, WordPress ou site statique : dossier, clone, `.env`, base MariaDB dédiée, `docker-compose.yml` (templates de la procédure), démarrage et bloc Caddy. | | Projets | Les projets de `/srv` (même organisation que la procédure). Pour chacun : dépôt git (version en ligne, nouveautés), mise à jour (`git pull` puis recréation des conteneurs), retour à un ancien commit, redémarrage, arrêt, mise à jour des images. **Création de projet** Node, Adonis, WordPress ou site statique : dossier, clone, `.env`, base MariaDB dédiée, `docker-compose.yml` (templates de la procédure), démarrage et bloc Caddy. |
| Caddy | Éditeur du Caddyfile. Si Caddy refuse la nouvelle configuration, l'ancien fichier est remis en place automatiquement. Historique des 50 dernières versions, restaurables. | | Caddy | Éditeur du Caddyfile. Si Caddy refuse la nouvelle configuration, l'ancien fichier est remis en place automatiquement. Historique des 50 dernières versions, restaurables. |
+18
View File
@@ -60,6 +60,24 @@ const routes = {
tokens: [{"old":"/api/containers/:id/logs","type":0,"val":"api","end":""},{"old":"/api/containers/:id/logs","type":0,"val":"containers","end":""},{"old":"/api/containers/:id/logs","type":1,"val":"id","end":""},{"old":"/api/containers/:id/logs","type":0,"val":"logs","end":""}], tokens: [{"old":"/api/containers/:id/logs","type":0,"val":"api","end":""},{"old":"/api/containers/:id/logs","type":0,"val":"containers","end":""},{"old":"/api/containers/:id/logs","type":1,"val":"id","end":""},{"old":"/api/containers/:id/logs","type":0,"val":"logs","end":""}],
types: placeholder as Registry['containers.logs']['types'], types: placeholder as Registry['containers.logs']['types'],
}, },
'compose.show': {
methods: ["GET","HEAD"],
pattern: '/api/containers/:id/compose',
tokens: [{"old":"/api/containers/:id/compose","type":0,"val":"api","end":""},{"old":"/api/containers/:id/compose","type":0,"val":"containers","end":""},{"old":"/api/containers/:id/compose","type":1,"val":"id","end":""},{"old":"/api/containers/:id/compose","type":0,"val":"compose","end":""}],
types: placeholder as Registry['compose.show']['types'],
},
'compose.update': {
methods: ["PUT"],
pattern: '/api/containers/:id/compose',
tokens: [{"old":"/api/containers/:id/compose","type":0,"val":"api","end":""},{"old":"/api/containers/:id/compose","type":0,"val":"containers","end":""},{"old":"/api/containers/:id/compose","type":1,"val":"id","end":""},{"old":"/api/containers/:id/compose","type":0,"val":"compose","end":""}],
types: placeholder as Registry['compose.update']['types'],
},
'compose.version': {
methods: ["GET","HEAD"],
pattern: '/api/containers/:id/compose/versions/:versionId',
tokens: [{"old":"/api/containers/:id/compose/versions/:versionId","type":0,"val":"api","end":""},{"old":"/api/containers/:id/compose/versions/:versionId","type":0,"val":"containers","end":""},{"old":"/api/containers/:id/compose/versions/:versionId","type":1,"val":"id","end":""},{"old":"/api/containers/:id/compose/versions/:versionId","type":0,"val":"compose","end":""},{"old":"/api/containers/:id/compose/versions/:versionId","type":0,"val":"versions","end":""},{"old":"/api/containers/:id/compose/versions/:versionId","type":1,"val":"versionId","end":""}],
types: placeholder as Registry['compose.version']['types'],
},
'containers.action': { 'containers.action': {
methods: ["POST"], methods: ["POST"],
pattern: '/api/containers/:id/:action', pattern: '/api/containers/:id/:action',
+36
View File
@@ -115,6 +115,42 @@ export interface Registry {
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/containers_controller').default['logs']>>> errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/containers_controller').default['logs']>>>
} }
} }
'compose.show': {
methods: ["GET","HEAD"]
pattern: '/api/containers/:id/compose'
types: {
body: {}
paramsTuple: [ParamValue]
params: { id: ParamValue }
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/compose_controller').default['show']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/compose_controller').default['show']>>>
}
}
'compose.update': {
methods: ["PUT"]
pattern: '/api/containers/:id/compose'
types: {
body: ExtractBody<InferInput<(typeof import('#validators/compose').updateComposeValidator)>>
paramsTuple: [ParamValue]
params: { id: ParamValue }
query: ExtractQuery<InferInput<(typeof import('#validators/compose').updateComposeValidator)>>
response: ExtractResponse<Awaited<ReturnType<import('#controllers/compose_controller').default['update']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/compose_controller').default['update']>>> | { status: 422; response: { errors: SimpleError[] } }
}
}
'compose.version': {
methods: ["GET","HEAD"]
pattern: '/api/containers/:id/compose/versions/:versionId'
types: {
body: {}
paramsTuple: [ParamValue, ParamValue]
params: { id: ParamValue; versionId: ParamValue }
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/compose_controller').default['version']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/compose_controller').default['version']>>>
}
}
'containers.action': { 'containers.action': {
methods: ["POST"] methods: ["POST"]
pattern: '/api/containers/:id/:action' pattern: '/api/containers/:id/:action'
+5
View File
@@ -20,6 +20,11 @@ export interface ApiDefinition {
logs: typeof routes['containers.logs'] logs: typeof routes['containers.logs']
action: typeof routes['containers.action'] action: typeof routes['containers.action']
} }
compose: {
show: typeof routes['compose.show']
update: typeof routes['compose.update']
version: typeof routes['compose.version']
}
sites: { sites: {
index: typeof routes['sites.index'] index: typeof routes['sites.index']
check: typeof routes['sites.check'] check: typeof routes['sites.check']
+1
View File
@@ -8,6 +8,7 @@ export const controllers = {
Auth: () => import('#controllers/auth_controller'), Auth: () => import('#controllers/auth_controller'),
Backups: () => import('#controllers/backups_controller'), Backups: () => import('#controllers/backups_controller'),
Caddy: () => import('#controllers/caddy_controller'), Caddy: () => import('#controllers/caddy_controller'),
Compose: () => import('#controllers/compose_controller'),
Containers: () => import('#controllers/containers_controller'), Containers: () => import('#controllers/containers_controller'),
Jobs: () => import('#controllers/jobs_controller'), Jobs: () => import('#controllers/jobs_controller'),
Metrics: () => import('#controllers/metrics_controller'), Metrics: () => import('#controllers/metrics_controller'),
+8
View File
@@ -13,6 +13,9 @@ export type ScannedRoutes = {
'containers.index': { paramsTuple?: []; params?: {} } 'containers.index': { paramsTuple?: []; params?: {} }
'containers.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } 'containers.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'containers.logs': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } 'containers.logs': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'compose.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'compose.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'compose.version': { paramsTuple: [ParamValue,ParamValue]; params: {'id': ParamValue,'versionId': ParamValue} }
'containers.action': { paramsTuple: [ParamValue,ParamValue]; params: {'id': ParamValue,'action': ParamValue} } 'containers.action': { paramsTuple: [ParamValue,ParamValue]; params: {'id': ParamValue,'action': ParamValue} }
'sites.index': { paramsTuple?: []; params?: {} } 'sites.index': { paramsTuple?: []; params?: {} }
'sites.check': { paramsTuple?: []; params?: {} } 'sites.check': { paramsTuple?: []; params?: {} }
@@ -58,6 +61,8 @@ export type ScannedRoutes = {
'containers.index': { paramsTuple?: []; params?: {} } 'containers.index': { paramsTuple?: []; params?: {} }
'containers.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } 'containers.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'containers.logs': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } 'containers.logs': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'compose.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'compose.version': { paramsTuple: [ParamValue,ParamValue]; params: {'id': ParamValue,'versionId': ParamValue} }
'sites.index': { paramsTuple?: []; params?: {} } 'sites.index': { paramsTuple?: []; params?: {} }
'sites.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } 'sites.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'projects.index': { paramsTuple?: []; params?: {} } 'projects.index': { paramsTuple?: []; params?: {} }
@@ -81,6 +86,8 @@ export type ScannedRoutes = {
'containers.index': { paramsTuple?: []; params?: {} } 'containers.index': { paramsTuple?: []; params?: {} }
'containers.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } 'containers.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'containers.logs': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } 'containers.logs': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'compose.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'compose.version': { paramsTuple: [ParamValue,ParamValue]; params: {'id': ParamValue,'versionId': ParamValue} }
'sites.index': { paramsTuple?: []; params?: {} } 'sites.index': { paramsTuple?: []; params?: {} }
'sites.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } 'sites.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'projects.index': { paramsTuple?: []; params?: {} } 'projects.index': { paramsTuple?: []; params?: {} }
@@ -97,6 +104,7 @@ export type ScannedRoutes = {
'spa.index': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} } 'spa.index': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} }
} }
PUT: { PUT: {
'compose.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'caddy.update': { paramsTuple?: []; params?: {} } 'caddy.update': { paramsTuple?: []; params?: {} }
} }
DELETE: { DELETE: {
+55
View File
@@ -0,0 +1,55 @@
import type { HttpContext } from '@adonisjs/core/http'
import composeService from '#services/compose_service'
import { actor, audit } from '#services/audit'
import { updateComposeValidator } from '#validators/compose'
const SAVE_ACTION = 'Modification du docker-compose.yml'
/**
* The docker-compose.yml of a container, from the container page.
*/
export default class ComposeController {
async show({ params }: HttpContext) {
return composeService.show(params.id)
}
async version({ params }: HttpContext) {
return composeService.version(params.id, Number(params.versionId))
}
async update(ctx: HttpContext) {
const { content, reason, apply } = await ctx.request.validateUsing(updateComposeValidator)
const target = await composeService.locate(ctx.params.id)
const result = await composeService.save(target, content, {
...actor(ctx),
reason: reason || 'Modification manuelle',
apply,
})
if (!result.ok) {
await audit(ctx, {
action: SAVE_ACTION,
target: target.file,
status: 'error',
output: result.output,
})
return ctx.response.unprocessableEntity({
message: 'docker compose a refusé le fichier : rien n’a été modifié.',
output: result.output,
})
}
/**
* When applied, the job records the action itself.
*/
if (result.changed && result.jobId === null) {
await audit(ctx, {
action: SAVE_ACTION,
target: target.file,
status: 'success',
output: result.output || null,
})
}
return result
}
}
+3
View File
@@ -0,0 +1,3 @@
import { ComposeVersionSchema } from '#database/schema'
export default class ComposeVersion extends ComposeVersionSchema {}
+3 -2
View File
@@ -2,6 +2,7 @@ import { readFile, writeFile } from 'node:fs/promises'
import vpsConfig from '#config/vps' import vpsConfig from '#config/vps'
import dockerService from '#services/docker_service' import dockerService from '#services/docker_service'
import CaddyfileVersion from '#models/caddyfile_version' import CaddyfileVersion from '#models/caddyfile_version'
import { toFileContent } from '#services/file_content'
export interface CaddySite { export interface CaddySite {
/** /**
@@ -170,8 +171,8 @@ class CaddyService {
meta: { username: string | null; reason: string } meta: { username: string | null; reason: string }
): Promise<ReloadResult> { ): Promise<ReloadResult> {
const previous = await this.read() const previous = await this.read()
const normalized = content.replace(/\r\n/g, '\n') const normalized = toFileContent(content)
if (normalized === previous) return { ok: true, output: 'Aucun changement.' } if (normalized === toFileContent(previous)) return { ok: true, output: 'Aucun changement.' }
await this.#write(normalized) await this.#write(normalized)
+314
View File
@@ -0,0 +1,314 @@
import { spawn } from 'node:child_process'
import { readFile, realpath, writeFile } from 'node:fs/promises'
import { isAbsolute, relative, resolve, sep } from 'node:path'
import vpsConfig from '#config/vps'
import UserError from '#exceptions/user_error'
import ComposeVersion from '#models/compose_version'
import dockerService from '#services/docker_service'
import { toFileContent } from '#services/file_content'
import jobRunner, { cleanEnv, type JobContext, type JobMeta } from '#services/job_runner'
export interface ComposeTarget {
project: string
service: string | null
workingDir: string
file: string
/**
* Containers of the same compose project: "up -d" may recreate them all.
*/
containers: string[]
editable: boolean
/**
* Why the file can only be read.
*/
readOnlyReason: string | null
}
export interface ComposeCheck {
ok: boolean
output: string
}
export type ComposeSaveResult =
| { ok: false; output: string }
| { ok: true; changed: boolean; output: string; jobId: number | null }
/**
* Versions kept for each file.
*/
const KEPT_VERSIONS = 30
/**
* Reads and edits the docker-compose.yml of a container, found with the
* labels "docker compose" puts on the containers it creates.
*/
class ComposeService {
async locate(containerId: string): Promise<ComposeTarget> {
const info = await dockerService.docker.getContainer(containerId).inspect()
const labels = info.Config.Labels ?? {}
const project = labels['com.docker.compose.project']
const workingDir = labels['com.docker.compose.project.working_dir']
const files = (labels['com.docker.compose.project.config_files'] ?? '')
.split(',')
.map((file) => file.trim())
.filter(Boolean)
if (!project || !workingDir || files.length === 0) {
throw new UserError(
'Ce conteneur n’a pas été créé avec docker compose : il n’a pas de fichier à modifier.',
404
)
}
const file = resolve(workingDir, files[0])
if (!isInside(vpsConfig.srvRoot, file)) {
throw new UserError(
`Le fichier ${file} est en dehors de ${vpsConfig.srvRoot} : le tableau de bord n’y a pas accès.`,
404
)
}
/**
* Same check once the symbolic links are resolved.
*/
const real = await Promise.all([realpath(vpsConfig.srvRoot), realpath(file)]).catch(() => null)
if (!real || !isInside(real[0], real[1])) {
throw new UserError(`Le fichier ${file} est introuvable.`, 404)
}
let readOnlyReason: string | null = null
if (files.length > 1) {
readOnlyReason = 'Ce projet combine plusieurs fichiers compose : modifie-les en SSH.'
} else if (await dockerService.isSelf(info.Id)) {
readOnlyReason =
'Le compose du tableau de bord se modifie en SSH : une erreur ici couperait l’accès au tableau de bord.'
}
const containers = await dockerService.list().catch(() => [])
return {
project,
service: labels['com.docker.compose.service'] ?? null,
workingDir,
file,
containers: containers
.filter((container) => container.composeProject === project)
.map((container) => container.name),
editable: readOnlyReason === null,
readOnlyReason,
}
}
async show(containerId: string) {
const target = await this.locate(containerId)
const [content, versions] = await Promise.all([
readFile(target.file, 'utf8'),
ComposeVersion.query()
.select('id', 'username', 'reason', 'created_at')
.where('path', target.file)
.orderBy('id', 'desc'),
])
return {
...target,
content,
versions: versions.map((version) => ({
id: version.id,
username: version.username,
reason: version.reason,
createdAt: version.createdAt.toISO(),
})),
}
}
async version(containerId: string, versionId: number) {
const target = await this.locate(containerId)
const version = await ComposeVersion.query()
.where('id', versionId)
.where('path', target.file)
.first()
if (!version) throw new UserError('Version introuvable.', 404)
return {
id: version.id,
username: version.username,
reason: version.reason,
createdAt: version.createdAt.toISO(),
content: version.content,
}
}
/**
* "docker compose config" on the new content, without writing it: catches
* YAML errors, unknown keys and missing env files.
*/
async check(target: ComposeTarget, content: string): Promise<ComposeCheck> {
const result = await runWithInput(
'docker',
[...composeArgs(target, '-'), 'config', '--quiet'],
{ cwd: target.workingDir, input: content, timeoutMs: 30_000 }
)
return { ok: result.code === 0, output: readableComposeOutput(result.output) }
}
/**
* Checks and writes the new file, then runs "docker compose up -d" when
* asked. When "up" fails, the previous file is put back and started again.
*/
async save(
target: ComposeTarget,
content: string,
options: Omit<JobMeta, 'action' | 'target' | 'lockKey'> & { reason: string; apply: boolean }
): Promise<ComposeSaveResult> {
if (!target.editable) throw new UserError(target.readOnlyReason ?? 'Fichier non modifiable.')
const lockKey = `project:${target.workingDir}`
if (jobRunner.isLocked(lockKey)) {
throw new UserError('Une action est déjà en cours sur ce projet, attends la fin.', 409)
}
const next = toFileContent(content)
const previous = await readFile(target.file, 'utf8')
const changed = next !== toFileContent(previous)
if (!changed && !options.apply)
return { ok: true, changed, output: 'Aucun changement.', jobId: null }
const check = await this.check(target, next)
if (!check.ok) return { ok: false, output: check.output }
if (changed) {
await ComposeVersion.create({
path: target.file,
content: previous,
username: options.username,
reason: options.reason,
})
await this.#write(target.file, next)
await this.#pruneVersions(target.file)
}
if (!options.apply) return { ok: true, changed, output: check.output, jobId: null }
const job = await jobRunner.start(
{
username: options.username,
ip: options.ip,
action: changed
? 'Modification du docker-compose.yml'
: 'Application du docker-compose.yml',
target: target.file,
lockKey,
},
async (ctx) => {
if (check.output) ctx.log(check.output)
ctx.log(
changed
? 'Fichier enregistré (la version précédente est dans l’historique).'
: 'Fichier inchangé : application de la version actuelle.'
)
try {
await this.#up(ctx, target)
} catch (error) {
if (!changed) throw error
ctx.log('Échec : remise en place de la version précédente…', 'err')
await this.#write(target.file, previous)
await this.#up(ctx, target).catch((rollbackError: Error) =>
ctx.log(
`La version précédente n’a pas pu être relancée : ${rollbackError.message}`,
'err'
)
)
throw new Error(
'La nouvelle version n’a pas pu être appliquée : la version précédente a été remise en place.'
)
}
}
)
return { ok: true, changed, output: check.output, jobId: job.id }
}
#up(ctx: JobContext, target: ComposeTarget) {
return ctx.run('docker', [...composeArgs(target, target.file), 'up', '-d'], {
cwd: target.workingDir,
})
}
/**
* Writes in place, like the Caddyfile: the file may be mounted on its own.
*/
#write(file: string, content: string) {
return writeFile(file, content, 'utf8')
}
async #pruneVersions(file: string) {
const kept = await ComposeVersion.query()
.select('id')
.where('path', file)
.orderBy('id', 'desc')
.limit(KEPT_VERSIONS)
if (kept.length < KEPT_VERSIONS) return
await ComposeVersion.query().where('path', file).where('id', '<', kept.at(-1)!.id).delete()
}
}
/**
* Same project name and folder as the existing containers, so that
* "up -d" updates them instead of creating new ones.
*/
function composeArgs(target: ComposeTarget, file: string) {
return ['compose', '-p', target.project, '--project-directory', target.workingDir, '-f', file]
}
function isInside(root: string, path: string) {
const rel = relative(root, path)
return rel !== '' && !isAbsolute(rel) && rel.split(sep)[0] !== '..'
}
/**
* "time=… level=warning msg="…"" -> "Avertissement : …".
*/
function readableComposeOutput(output: string) {
return output
.split(/\r?\n/)
.filter((line) => line.trim())
.map((line) => {
const match = /level=(\w+)\s+msg="((?:[^"\\]|\\.)*)"/.exec(line)
if (!match) return line
const text = match[2].replace(/\\"/g, '"')
return match[1] === 'warning' ? `Avertissement : ${text}` : text
})
.join('\n')
}
function runWithInput(
command: string,
args: string[],
options: { cwd: string; input: string; timeoutMs: number }
) {
return new Promise<{ code: number | null; output: string }>((done) => {
const child = spawn(command, args, {
cwd: options.cwd,
env: cleanEnv(),
stdio: ['pipe', 'pipe', 'pipe'],
})
let output = ''
const timer = setTimeout(() => child.kill('SIGKILL'), options.timeoutMs)
child.stdout.on('data', (chunk) => (output += chunk))
child.stderr.on('data', (chunk) => (output += chunk))
child.on('error', (error) => {
clearTimeout(timer)
done({ code: null, output: `Impossible de lancer "${command}" : ${error.message}` })
})
child.on('close', (code) => {
clearTimeout(timer)
done({ code, output })
})
child.stdin.on('error', () => {})
child.stdin.end(options.input)
})
}
const composeService = new ComposeService()
export default composeService
+8
View File
@@ -0,0 +1,8 @@
/**
* Content written to a configuration file edited from the dashboard:
* "\n" line breaks and a final line break (the request body comes trimmed).
*/
export function toFileContent(content: string) {
const normalized = content.replace(/\r\n/g, '\n')
return normalized.endsWith('\n') ? normalized : `${normalized}\n`
}
+7
View File
@@ -0,0 +1,7 @@
import vine from '@vinejs/vine'
export const updateComposeValidator = vine.create({
content: vine.string().minLength(1).maxLength(200_000),
reason: vine.string().trim().maxLength(200).optional(),
apply: vine.boolean(),
})
@@ -0,0 +1,24 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Previous contents of the docker-compose.yml files, saved before every
* change made from the dashboard.
*/
export default class extends BaseSchema {
protected tableName = 'compose_versions'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.string('path').notNullable().index()
table.text('content').notNullable()
table.string('username').nullable()
table.string('reason').nullable()
table.timestamp('created_at').notNullable()
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}
+17
View File
@@ -45,6 +45,23 @@ export class CaddyfileVersionSchema extends BaseModel {
declare username: string | null declare username: string | null
} }
export class ComposeVersionSchema extends BaseModel {
static $columns = ['content', 'createdAt', 'id', 'path', 'reason', 'username'] as const
$columns = ComposeVersionSchema.$columns
@column()
declare content: string
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column({ isPrimary: true })
declare id: number
@column()
declare path: string
@column()
declare reason: string | null
@column()
declare username: string | null
}
export class MetricSchema extends BaseModel { export class MetricSchema extends BaseModel {
static $columns = ['cpu', 'diskTotal', 'diskUsed', 'id', 'load1', 'memTotal', 'memUsed', 'netRx', 'netTx', 'ts'] as const static $columns = ['cpu', 'diskTotal', 'diskUsed', 'id', 'load1', 'memTotal', 'memUsed', 'netRx', 'netTx', 'ts'] as const
$columns = MetricSchema.$columns $columns = MetricSchema.$columns
+3
View File
@@ -28,6 +28,9 @@ router
router.get('containers', [controllers.Containers, 'index']) router.get('containers', [controllers.Containers, 'index'])
router.get('containers/:id', [controllers.Containers, 'show']) router.get('containers/:id', [controllers.Containers, 'show'])
router.get('containers/:id/logs', [controllers.Containers, 'logs']) router.get('containers/:id/logs', [controllers.Containers, 'logs'])
router.get('containers/:id/compose', [controllers.Compose, 'show'])
router.put('containers/:id/compose', [controllers.Compose, 'update'])
router.get('containers/:id/compose/versions/:versionId', [controllers.Compose, 'version'])
router.post('containers/:id/:action', [controllers.Containers, 'action']) router.post('containers/:id/:action', [controllers.Containers, 'action'])
router.get('sites', [controllers.Sites, 'index']) router.get('sites', [controllers.Sites, 'index'])
+6
View File
@@ -30,6 +30,12 @@ export const routes: Routes = [
loadComponent: () => loadComponent: () =>
import('./pages/containers/container-detail').then((m) => m.ContainerDetailPage), import('./pages/containers/container-detail').then((m) => m.ContainerDetailPage),
}, },
{
path: 'containers/:id/compose',
title: 'docker-compose.yml · Big Brother',
loadComponent: () =>
import('./pages/containers/container-compose').then((m) => m.ContainerComposePage),
},
{ {
path: 'sites', path: 'sites',
title: 'Sites · Big Brother', title: 'Sites · Big Brother',
+16
View File
@@ -8,6 +8,9 @@ import type {
BackupSchedule, BackupSchedule,
CaddyInfo, CaddyInfo,
CaddyVersion, CaddyVersion,
ComposeInfo,
ComposeSaveResult,
ComposeVersion,
ContainerAction, ContainerAction,
ContainerDetails, ContainerDetails,
ContainerSummary, ContainerSummary,
@@ -82,6 +85,19 @@ export class Api {
containerAction(id: string, action: ContainerAction) { containerAction(id: string, action: ContainerAction) {
return this.send<ContainerDetails>('POST', `/containers/${encodeURIComponent(id)}/${action}`); return this.send<ContainerDetails>('POST', `/containers/${encodeURIComponent(id)}/${action}`);
} }
compose(id: string) {
return this.get<ComposeInfo>(`/containers/${enc(id)}/compose`);
}
composeVersion(id: string, versionId: number) {
return this.get<ComposeVersion>(`/containers/${enc(id)}/compose/versions/${versionId}`);
}
saveCompose(id: string, content: string, reason: string, apply: boolean) {
return this.send<ComposeSaveResult>('PUT', `/containers/${enc(id)}/compose`, {
content,
reason,
apply,
});
}
// Sites // Sites
sites() { sites() {
+33
View File
@@ -246,6 +246,39 @@ export interface CaddyVersion {
content?: string; content?: string;
} }
export interface ComposeVersion {
id: number;
username: string | null;
reason: string | null;
createdAt: string;
content?: string;
}
export interface ComposeInfo {
project: string;
service: string | null;
workingDir: string;
file: string;
/**
* Containers of the same compose project.
*/
containers: string[];
editable: boolean;
readOnlyReason: string | null;
content: string;
versions: ComposeVersion[];
}
export interface ComposeSaveResult {
ok: true;
changed: boolean;
output: string;
/**
* Set when "docker compose up -d" was started.
*/
jobId: number | null;
}
export interface JobLine { export interface JobLine {
ts: number; ts: number;
stream: 'out' | 'err' | 'info'; stream: 'out' | 'err' | 'info';
+1 -54
View File
@@ -1,43 +1,4 @@
.layout { .site-list {
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; margin: 0;
padding: 0; padding: 0;
list-style: none; 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', selector: 'app-caddy',
imports: [FormsModule, Icon], imports: [FormsModule, Icon],
templateUrl: './caddy.html', templateUrl: './caddy.html',
styleUrl: './caddy.scss', styleUrls: ['../../shared/editor-page.scss', './caddy.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class CaddyPage { 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> </div>
@if (container(); as c) { @if (container(); as c) {
<div class="toolbar"> <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') { @if (c.state === 'running') {
<button type="button" class="btn" (click)="act('restart')" [disabled]="busy()"> <button type="button" class="btn" (click)="act('restart')" [disabled]="busy()">
<app-icon name="restart" /> Redémarrer <app-icon name="restart" /> Redémarrer
@@ -232,6 +232,11 @@
<section class="card"> <section class="card">
<div class="card-header"> <div class="card-header">
<h2>{{ p.composeFile }}</h2> <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>
<div class="card-body"> <div class="card-body">
<pre class="code-block">{{ p.compose }}</pre> <pre class="code-block">{{ p.compose }}</pre>
+68
View File
@@ -0,0 +1,68 @@
// Pages with a file editor and its previous versions (Caddyfile, docker-compose.yml).
.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;
}
.version-list {
margin: 0;
padding: 0;
list-style: none;
li {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 18px;
border-bottom: 1px solid var(--border);
overflow-wrap: anywhere;
&:last-child {
border-bottom: 0;
}
}
.muted {
display: block;
font-size: 12.5px;
}
}
.version-actions {
display: flex;
gap: 6px;
}
+1
View File
@@ -58,6 +58,7 @@ const ICONS = {
table: table:
'<path d="M12 3v18"/><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/>', '<path d="M12 3v18"/><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/>',
chart: '<path d="M3 3v16a2 2 0 0 0 2 2h16"/><path d="m19 9-5 5-4-4-3 3"/>', chart: '<path d="M3 3v16a2 2 0 0 0 2 2h16"/><path d="m19 9-5 5-4-4-3 3"/>',
file: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/>',
} as const; } as const;
export type IconName = keyof typeof ICONS; export type IconName = keyof typeof ICONS;