É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:
1 parent
dcdac915f9
commit
c106fcfc62
27 files changed
+1035
-58
No files matched your search
@@ -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. |
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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']
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
Vendored
+8
@@ -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: {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { ComposeVersionSchema } from '#database/schema'
|
||||||
|
|
||||||
|
export default class ComposeVersion extends ComposeVersionSchema {}
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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`
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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'])
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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,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;
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user