É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 |
|
||||
|---|---|
|
||||
| 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. |
|
||||
| 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. |
|
||||
|
||||
@@ -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":""}],
|
||||
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': {
|
||||
methods: ["POST"],
|
||||
pattern: '/api/containers/:id/:action',
|
||||
|
||||
+36
@@ -115,6 +115,42 @@ export interface Registry {
|
||||
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': {
|
||||
methods: ["POST"]
|
||||
pattern: '/api/containers/:id/:action'
|
||||
|
||||
+5
@@ -20,6 +20,11 @@ export interface ApiDefinition {
|
||||
logs: typeof routes['containers.logs']
|
||||
action: typeof routes['containers.action']
|
||||
}
|
||||
compose: {
|
||||
show: typeof routes['compose.show']
|
||||
update: typeof routes['compose.update']
|
||||
version: typeof routes['compose.version']
|
||||
}
|
||||
sites: {
|
||||
index: typeof routes['sites.index']
|
||||
check: typeof routes['sites.check']
|
||||
|
||||
@@ -8,6 +8,7 @@ export const controllers = {
|
||||
Auth: () => import('#controllers/auth_controller'),
|
||||
Backups: () => import('#controllers/backups_controller'),
|
||||
Caddy: () => import('#controllers/caddy_controller'),
|
||||
Compose: () => import('#controllers/compose_controller'),
|
||||
Containers: () => import('#controllers/containers_controller'),
|
||||
Jobs: () => import('#controllers/jobs_controller'),
|
||||
Metrics: () => import('#controllers/metrics_controller'),
|
||||
|
||||
Vendored
+8
@@ -13,6 +13,9 @@ export type ScannedRoutes = {
|
||||
'containers.index': { paramsTuple?: []; params?: {} }
|
||||
'containers.show': { 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} }
|
||||
'sites.index': { paramsTuple?: []; params?: {} }
|
||||
'sites.check': { paramsTuple?: []; params?: {} }
|
||||
@@ -58,6 +61,8 @@ export type ScannedRoutes = {
|
||||
'containers.index': { paramsTuple?: []; params?: {} }
|
||||
'containers.show': { 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.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
|
||||
'projects.index': { paramsTuple?: []; params?: {} }
|
||||
@@ -81,6 +86,8 @@ export type ScannedRoutes = {
|
||||
'containers.index': { paramsTuple?: []; params?: {} }
|
||||
'containers.show': { 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.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
|
||||
'projects.index': { paramsTuple?: []; params?: {} }
|
||||
@@ -97,6 +104,7 @@ export type ScannedRoutes = {
|
||||
'spa.index': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} }
|
||||
}
|
||||
PUT: {
|
||||
'compose.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
|
||||
'caddy.update': { paramsTuple?: []; params?: {} }
|
||||
}
|
||||
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 dockerService from '#services/docker_service'
|
||||
import CaddyfileVersion from '#models/caddyfile_version'
|
||||
import { toFileContent } from '#services/file_content'
|
||||
|
||||
export interface CaddySite {
|
||||
/**
|
||||
@@ -170,8 +171,8 @@ class CaddyService {
|
||||
meta: { username: string | null; reason: string }
|
||||
): Promise<ReloadResult> {
|
||||
const previous = await this.read()
|
||||
const normalized = content.replace(/\r\n/g, '\n')
|
||||
if (normalized === previous) return { ok: true, output: 'Aucun changement.' }
|
||||
const normalized = toFileContent(content)
|
||||
if (normalized === toFileContent(previous)) return { ok: true, output: 'Aucun changement.' }
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
static $columns = ['cpu', 'diskTotal', 'diskUsed', 'id', 'load1', 'memTotal', 'memUsed', 'netRx', 'netTx', 'ts'] as const
|
||||
$columns = MetricSchema.$columns
|
||||
|
||||
@@ -28,6 +28,9 @@ router
|
||||
router.get('containers', [controllers.Containers, 'index'])
|
||||
router.get('containers/:id', [controllers.Containers, 'show'])
|
||||
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.get('sites', [controllers.Sites, 'index'])
|
||||
|
||||
@@ -30,6 +30,12 @@ export const routes: Routes = [
|
||||
loadComponent: () =>
|
||||
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',
|
||||
title: 'Sites · Big Brother',
|
||||
|
||||
@@ -8,6 +8,9 @@ import type {
|
||||
BackupSchedule,
|
||||
CaddyInfo,
|
||||
CaddyVersion,
|
||||
ComposeInfo,
|
||||
ComposeSaveResult,
|
||||
ComposeVersion,
|
||||
ContainerAction,
|
||||
ContainerDetails,
|
||||
ContainerSummary,
|
||||
@@ -82,6 +85,19 @@ export class Api {
|
||||
containerAction(id: string, action: ContainerAction) {
|
||||
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() {
|
||||
|
||||
@@ -246,6 +246,39 @@ export interface CaddyVersion {
|
||||
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 {
|
||||
ts: number;
|
||||
stream: 'out' | 'err' | 'info';
|
||||
|
||||
@@ -1,43 +1,4 @@
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 340px;
|
||||
align-items: start;
|
||||
gap: 16px;
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.editor {
|
||||
min-height: 520px;
|
||||
white-space: pre;
|
||||
overflow-wrap: normal;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.save-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.output {
|
||||
max-height: 240px;
|
||||
margin: 8px 0 0;
|
||||
overflow: auto;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.site-list,
|
||||
.version-list {
|
||||
.site-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
@@ -55,17 +16,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.version-list li {
|
||||
gap: 8px;
|
||||
|
||||
.muted {
|
||||
display: block;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.version-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { Icon } from '../../shared/icon';
|
||||
selector: 'app-caddy',
|
||||
imports: [FormsModule, Icon],
|
||||
templateUrl: './caddy.html',
|
||||
styleUrl: './caddy.scss',
|
||||
styleUrls: ['../../shared/editor-page.scss', './caddy.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class CaddyPage {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<a [routerLink]="['/containers', id()]" class="back-link"
|
||||
><app-icon name="back" /> {{ id() }}</a
|
||||
>
|
||||
<h1>{{ fileName() }}</h1>
|
||||
@if (info(); as i) {
|
||||
<p>
|
||||
Projet compose <strong>{{ i.project }}</strong> ·
|
||||
<span class="mono">{{ i.file }}</span>
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (error()) {
|
||||
<div class="alert critical" role="alert">
|
||||
<app-icon name="critical" />
|
||||
<p>{{ error() }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (jobId(); as job) {
|
||||
<app-job-console [jobId]="job" (finished)="onFinished($event)" />
|
||||
}
|
||||
|
||||
@if (info(); as i) {
|
||||
@if (!i.editable) {
|
||||
<div class="alert info">
|
||||
<app-icon name="lock" />
|
||||
<p>{{ i.readOnlyReason }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="layout">
|
||||
<section class="card editor-card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h2>{{ fileName() }}</h2>
|
||||
<p>{{ lineCount() }} lignes{{ dirty() ? ' · modifications non enregistrées' : '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body stack">
|
||||
<textarea
|
||||
class="textarea editor"
|
||||
spellcheck="false"
|
||||
autocapitalize="off"
|
||||
[attr.aria-label]="'Contenu de ' + fileName()"
|
||||
[readOnly]="!i.editable"
|
||||
[ngModel]="content()"
|
||||
(ngModelChange)="content.set($event)"
|
||||
(keydown)="onKeydown($event)"
|
||||
></textarea>
|
||||
|
||||
@if (output(); as out) {
|
||||
<div class="alert" [class.info]="out.ok" [class.critical]="!out.ok" role="status">
|
||||
<app-icon [name]="out.ok ? 'warning' : 'critical'" />
|
||||
<div>
|
||||
<p>
|
||||
{{
|
||||
out.ok
|
||||
? 'docker compose a accepté le fichier, avec ces remarques :'
|
||||
: 'docker compose a refusé le fichier : rien n’a été modifié.'
|
||||
}}
|
||||
</p>
|
||||
<pre class="output">{{ out.text }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (i.editable) {
|
||||
<div class="save-row">
|
||||
<input
|
||||
class="input"
|
||||
placeholder="Motif (facultatif), ex. : port retiré"
|
||||
[ngModel]="reason()"
|
||||
(ngModelChange)="reason.set($event)"
|
||||
aria-label="Motif de la modification"
|
||||
/>
|
||||
<button type="button" class="btn" (click)="reset()" [disabled]="!dirty() || busy()">
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
(click)="save(false)"
|
||||
[disabled]="!dirty() || busy()"
|
||||
>
|
||||
<app-icon name="save" /> Enregistrer
|
||||
</button>
|
||||
<button type="button" class="btn primary" (click)="save(true)" [disabled]="busy()">
|
||||
@if (busy()) {
|
||||
<app-icon name="loader" class="spin" />
|
||||
} @else {
|
||||
<app-icon name="play" />
|
||||
}
|
||||
{{ dirty() ? 'Enregistrer et appliquer' : 'Appliquer' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="hint">
|
||||
Le fichier est toujours vérifié par <span class="mono">docker compose config</span>
|
||||
avant d’être écrit. « Enregistrer » ne touche pas aux conteneurs : les changements
|
||||
s’appliquent au prochain démarrage du projet. « Appliquer » lance
|
||||
<span class="mono">docker compose up -d</span> tout de suite.
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="stack">
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h2>Conteneurs du projet</h2>
|
||||
<p>Ceux que l’application peut recréer</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body flush">
|
||||
<ul class="container-list">
|
||||
@for (name of i.containers; track name) {
|
||||
<li>
|
||||
<a [routerLink]="['/containers', name]">{{ name }}</a>
|
||||
@if (name === id()) {
|
||||
<span class="pill">ce conteneur</span>
|
||||
}
|
||||
</li>
|
||||
} @empty {
|
||||
<li class="muted">Aucun conteneur en cours</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h2>Versions précédentes</h2>
|
||||
<p>Sauvegardées avant chaque modification</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body flush">
|
||||
<ul class="version-list">
|
||||
@for (version of i.versions; track version.id) {
|
||||
<li>
|
||||
<div>
|
||||
<strong>#{{ version.id }}</strong> · {{ dateTime(version.createdAt) }}
|
||||
<span class="muted"
|
||||
>{{ version.reason ?? ''
|
||||
}}{{ version.username ? ' · ' + version.username : '' }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="version-actions">
|
||||
<button type="button" class="btn small" (click)="view(version)">Voir</button>
|
||||
</div>
|
||||
</li>
|
||||
} @empty {
|
||||
<li class="muted">
|
||||
Pas encore de version (elles apparaissent après la première modification depuis le
|
||||
tableau de bord).
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@if (viewed(); as version) {
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<h2>Version #{{ version.id }} · {{ dateTime(version.createdAt) }}</h2>
|
||||
<div class="toolbar">
|
||||
@if (i.editable) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn small"
|
||||
(click)="loadVersion(version)"
|
||||
[disabled]="busy()"
|
||||
>
|
||||
Charger dans l’éditeur
|
||||
</button>
|
||||
}
|
||||
<button type="button" class="btn small ghost" (click)="viewed.set(null)">Fermer</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<pre class="code-block">{{ version.content }}</pre>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,25 @@
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.container-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Api } from '../../core/api';
|
||||
import { Feedback } from '../../core/feedback';
|
||||
import { errorMessage, formatDateTime } from '../../core/format';
|
||||
import type { AuditStatus, ComposeInfo, ComposeVersion } from '../../core/models';
|
||||
import { Icon } from '../../shared/icon';
|
||||
import { JobConsole } from '../../shared/job-console';
|
||||
|
||||
/**
|
||||
* The textarea gives back "\n" line breaks: the file is compared the same way.
|
||||
*/
|
||||
function normalize(content: string) {
|
||||
return content.replace(/\r\n/g, '\n');
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-container-compose',
|
||||
imports: [FormsModule, RouterLink, Icon, JobConsole],
|
||||
templateUrl: './container-compose.html',
|
||||
styleUrls: ['../../shared/editor-page.scss', './container-compose.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ContainerComposePage {
|
||||
private api = inject(Api);
|
||||
private feedback = inject(Feedback);
|
||||
|
||||
/**
|
||||
* Container name or id (route parameter).
|
||||
*/
|
||||
readonly id = input.required<string>();
|
||||
|
||||
protected readonly info = signal<ComposeInfo | null>(null);
|
||||
protected readonly content = signal('');
|
||||
protected readonly reason = signal('');
|
||||
protected readonly busy = signal(false);
|
||||
protected readonly error = signal<string | null>(null);
|
||||
protected readonly output = signal<{ ok: boolean; text: string } | null>(null);
|
||||
protected readonly viewed = signal<ComposeVersion | null>(null);
|
||||
protected readonly jobId = signal<number | null>(null);
|
||||
|
||||
protected readonly dirty = computed(() => {
|
||||
const info = this.info();
|
||||
return info !== null && this.content() !== normalize(info.content);
|
||||
});
|
||||
protected readonly lineCount = computed(() => this.content().split('\n').length);
|
||||
protected readonly fileName = computed(
|
||||
() => this.info()?.file.split(/[\\/]/).pop() ?? 'docker-compose.yml',
|
||||
);
|
||||
protected readonly dateTime = formatDateTime;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const id = this.id();
|
||||
untracked(() => this.load(id));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* keepContent: after a failed apply, the edited text stays in the editor.
|
||||
*/
|
||||
protected async load(id = this.id(), keepContent = false) {
|
||||
try {
|
||||
const info = await this.api.compose(id);
|
||||
this.info.set(info);
|
||||
if (!keepContent) this.content.set(normalize(info.content));
|
||||
this.error.set(null);
|
||||
} catch (error) {
|
||||
this.error.set(errorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
protected async save(apply: boolean) {
|
||||
const info = this.info();
|
||||
if (!info) return;
|
||||
|
||||
if (apply) {
|
||||
const others = info.containers.length > 1 ? ` (${info.containers.join(', ')})` : '';
|
||||
const ok = await this.feedback.confirm({
|
||||
title: this.dirty() ? 'Enregistrer et appliquer ?' : 'Appliquer le fichier ?',
|
||||
message: `« docker compose up -d » recrée les conteneurs du projet ${info.project}${others} dont la configuration a changé : ils sont coupés quelques secondes. Si la commande échoue, la version précédente est remise en place et relancée.`,
|
||||
confirmLabel: this.dirty() ? 'Enregistrer et appliquer' : 'Appliquer',
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
this.busy.set(true);
|
||||
this.output.set(null);
|
||||
this.jobId.set(null);
|
||||
try {
|
||||
const result = await this.api.saveCompose(
|
||||
this.id(),
|
||||
this.content(),
|
||||
this.reason().trim(),
|
||||
apply,
|
||||
);
|
||||
this.reason.set('');
|
||||
if (result.output && result.output !== 'Aucun changement.') {
|
||||
this.output.set({ ok: true, text: result.output });
|
||||
}
|
||||
if (result.jobId !== null) {
|
||||
/**
|
||||
* The page stays busy until the job console reports the end.
|
||||
*/
|
||||
this.jobId.set(result.jobId);
|
||||
return;
|
||||
}
|
||||
this.feedback.success(
|
||||
result.changed ? `${this.fileName()} enregistré.` : 'Aucun changement.',
|
||||
);
|
||||
await this.load();
|
||||
} catch (error) {
|
||||
const body = error instanceof HttpErrorResponse ? error.error : null;
|
||||
this.output.set({ ok: false, text: body?.output || errorMessage(error) });
|
||||
this.feedback.error(errorMessage(error));
|
||||
} finally {
|
||||
if (this.jobId() === null) this.busy.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected async onFinished(status: AuditStatus) {
|
||||
this.busy.set(false);
|
||||
if (status === 'success') {
|
||||
this.feedback.success(`${this.fileName()} appliqué.`);
|
||||
await this.load();
|
||||
} else {
|
||||
this.feedback.error('L’application a échoué : le détail est dans la console.');
|
||||
await this.load(this.id(), true);
|
||||
}
|
||||
}
|
||||
|
||||
protected reset() {
|
||||
this.content.set(normalize(this.info()?.content ?? ''));
|
||||
this.output.set(null);
|
||||
}
|
||||
|
||||
protected async view(version: ComposeVersion) {
|
||||
try {
|
||||
this.viewed.set(await this.api.composeVersion(this.id(), version.id));
|
||||
} catch (error) {
|
||||
this.feedback.error(errorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
protected loadVersion(version: ComposeVersion) {
|
||||
this.content.set(normalize(version.content ?? ''));
|
||||
this.viewed.set(null);
|
||||
this.output.set(null);
|
||||
this.feedback.info(
|
||||
`Version #${version.id} chargée dans l’éditeur : enregistre pour la remettre.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab inserts two spaces: YAML does not accept tabs for indentation.
|
||||
*/
|
||||
protected onKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Tab' || event.shiftKey) return;
|
||||
event.preventDefault();
|
||||
const textarea = event.target as HTMLTextAreaElement;
|
||||
const { selectionStart, selectionEnd, value } = textarea;
|
||||
const next = `${value.slice(0, selectionStart)} ${value.slice(selectionEnd)}`;
|
||||
this.content.set(next);
|
||||
textarea.value = next;
|
||||
textarea.selectionStart = textarea.selectionEnd = selectionStart + 2;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,11 @@
|
||||
</div>
|
||||
@if (container(); as c) {
|
||||
<div class="toolbar">
|
||||
@if (c.composeProject) {
|
||||
<a class="btn" [routerLink]="['/containers', c.name, 'compose']">
|
||||
<app-icon name="file" /> docker-compose.yml
|
||||
</a>
|
||||
}
|
||||
@if (c.state === 'running') {
|
||||
<button type="button" class="btn" (click)="act('restart')" [disabled]="busy()">
|
||||
<app-icon name="restart" /> Redémarrer
|
||||
|
||||
@@ -232,6 +232,11 @@
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<h2>{{ p.composeFile }}</h2>
|
||||
@if (p.containers.length) {
|
||||
<a class="btn small" [routerLink]="['/containers', p.containers[0].name, 'compose']">
|
||||
<app-icon name="file" /> Modifier
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<pre class="code-block">{{ p.compose }}</pre>
|
||||
|
||||
@@ -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:
|
||||
'<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"/>',
|
||||
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;
|
||||
|
||||
export type IconName = keyof typeof ICONS;
|
||||
|
||||
Reference in new issue
Block a user