From a28eed0729090b570f83db6570eb4c18b9e5ec39 Mon Sep 17 00:00:00 2001 From: librelad Date: Fri, 12 Jun 2026 23:26:40 +0100 Subject: [PATCH] fix(services): route per-service restart through the task system + CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Services tab restart button POSTed to a backend endpoint that (a) checked the app's compose path from INSIDE the webui container, where the host's containers root isn't mounted — so every restart failed with 'Compose file not found' — and (b) queued a raw 'docker compose restart' that the host task processor would run as the manager user, which can't talk to the rootless daemon anyway. Errors surfaced via a bare alert(). Per-service restart now follows the exact shape of the whole-app verbs: - CLI: 'libreportal app restart [service]' — the optional service arg makes dockerRestartApp restart just that compose service, via dockerCommandRun (right user in rootless mode) from the app dir on the host, where the compose file actually lives. Service names validated against compose-legal characters before touching a shell line. - WebUI: the button dispatches a 'service_restart' task action through the task router (mutations-via-tasks), runs in the background with the standard task toast + link — no page switch — and failures use the notification system instead of alert(). Because the task runs host- side, restarting the WebUI's own libreportal-service now works too. - Backend: the mutating restart endpoint and its now-unused helpers are removed; service-routes.js is read-only surface (status + log tails). Co-Authored-By: Claude Fable 5 Signed-off-by: librelad --- .../backend/routes/service-routes.js | 64 ++----------------- .../apps/services/js/services-manager.js | 27 +++++--- .../frontend/core/tasks/js/task-actions.js | 19 ++++++ .../frontend/core/tasks/js/task-commands.js | 4 ++ .../frontend/core/tasks/js/task-router.js | 5 +- scripts/cli/commands/app/cli_app_commands.sh | 7 +- scripts/cli/commands/app/cli_app_header.sh | 3 +- scripts/docker/app/docker/restart_app.sh | 25 +++++++- 8 files changed, 81 insertions(+), 73 deletions(-) diff --git a/containers/libreportal/backend/routes/service-routes.js b/containers/libreportal/backend/routes/service-routes.js index eed0a9a..17968f3 100644 --- a/containers/libreportal/backend/routes/service-routes.js +++ b/containers/libreportal/backend/routes/service-routes.js @@ -11,9 +11,11 @@ // directly over the unix socket. That means no extra system deps and // no group-level privilege grants — node only sees what the mounted // socket lets it see. -// - Restart still goes through the existing task system. The bash task -// processor runs on the host (where `docker` IS available) so its -// `docker compose restart …` command works fine. +// - This file is READ-ONLY surface (status + log tails). Restarting a +// service is a mutation, so it goes through the task system like every +// other mutation: the Services tab dispatches a `service_restart` task +// (core/tasks) that runs `libreportal app restart ` on +// the host, where `docker` IS available and runs as the right user. // - URLs / port chips for each service are read client-side from the // existing /data/apps/generated/apps-services.json — no backend // surface needed for that. @@ -25,18 +27,10 @@ const path = require('path'); const http = require('http'); const { spawn } = require('child_process'); const { requireAuth } = require('../utils/middleware.js'); -const { pokeFifo } = require('../utils/fifo.js'); const { fileConfig } = require('../utils/config.js'); const router = express.Router(); -const TASKS_DIR = path.join(__dirname, '..', '..', 'frontend', 'data', 'tasks'); -const FIFO_PATH = path.join(TASKS_DIR, '.queue.fifo'); -// Host live-app-data root. Provided by the compose env (LP_CONTAINERS_DIR, filled -// from the host's containers root at generation — see scripts/source/paths.sh). -// Falls back to the legacy /docker path so a container that hasn't been recreated -// since the split-layout change keeps working until it is. -const CONTAINERS_DIR = process.env.LP_CONTAINERS_DIR || '/docker/containers'; const APPS_SERVICES_JSON = path.join(__dirname, '..', '..', 'frontend', 'data', 'apps', 'generated', 'apps-services.json'); // ===================================================================== @@ -309,10 +303,6 @@ async function lookupServiceTransport(appName, serviceName) { return { transport: 'docker' }; } -function appComposeFile(appName) { - return path.join(CONTAINERS_DIR, appName, 'docker-compose.yml'); -} - // ===================================================================== // GET /api/apps/:appName/services/status // → [{ serviceName, state, statusText, containerName, containerId }] @@ -372,50 +362,6 @@ router.get('/:appName/services/status', requireAuth, async (req, res) => { } }); -// ===================================================================== -// POST /api/apps/:appName/services/:serviceName/restart -// Creates a task that runs `docker compose restart ` on the -// host. The host has `docker` available; this container does not. -// ===================================================================== -router.post('/:appName/services/:serviceName/restart', requireAuth, async (req, res) => { - const { appName, serviceName } = req.params; - if (!safeName(appName) || !safeName(serviceName)) { - return res.status(400).json({ error: 'Invalid app or service name' }); - } - const compose = appComposeFile(appName); - if (!fs.existsSync(compose)) { - return res.status(404).json({ error: `Compose file not found: ${compose}` }); - } - - const id = `task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; - const task = { - id, - command: `docker compose -f "${compose}" restart "${serviceName}"`, - type: 'service-restart', - app: appName, - config: serviceName, - status: 'queued', - createdAt: new Date().toISOString(), - startedAt: null, - completedAt: null, - heartbeatAt: null, - exitCode: null, - errorMessage: null - }; - - try { - await fsp.mkdir(TASKS_DIR, { recursive: true }); - const taskPath = path.join(TASKS_DIR, `${id}.json`); - const tmp = `${taskPath}.tmp`; - await fsp.writeFile(tmp, JSON.stringify(task, null, 2)); - await fsp.rename(tmp, taskPath); - pokeFifo(FIFO_PATH, id); - res.status(201).json(task); - } catch (err) { - res.status(500).json({ error: err.message }); - } -}); - // ===================================================================== // GET /api/apps/:appName/services/:serviceName/logs // SSE-wraps the Docker /containers//logs?follow=1 stream. diff --git a/containers/libreportal/frontend/components/apps/services/js/services-manager.js b/containers/libreportal/frontend/components/apps/services/js/services-manager.js index ae776b5..672154c 100644 --- a/containers/libreportal/frontend/components/apps/services/js/services-manager.js +++ b/containers/libreportal/frontend/components/apps/services/js/services-manager.js @@ -575,24 +575,33 @@ class ServicesManager { }); } + // Restart one compose service as a background task: routed through the task + // system to the locked-down CLI (libreportal app restart ) — + // same path as the whole-app restart, scoped to one service. The task runs + // host-side, so this works even when the target is the WebUI's own service. + // No page switch; the task toast (with a link) comes from the task system, + // and the rows below re-poll their live status. async _restartService(serviceName, btn) { if (!this.currentApp) return; btn.disabled = true; btn.classList.add('is-running'); try { - const resp = await fetch( - `/api/apps/${encodeURIComponent(this.currentApp)}/services/${encodeURIComponent(serviceName)}/restart`, - { method: 'POST', headers: { 'Content-Type': 'application/json' } } - ); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.error || `HTTP ${resp.status}`); + const route = window.LP?.services?.tasks?.route || window.tasksManager?.router; + if (route && typeof route.routeAction === 'function') { + await route.routeAction('service_restart', { appName: this.currentApp, service: serviceName }); + } else if (typeof route === 'function') { + await route('service_restart', { appName: this.currentApp, service: serviceName }); + } else { + throw new Error('Task system not ready — try again in a moment.'); } - // Background task processor picks it up — refresh status shortly. + // The restart runs in the background — refresh status as it lands. setTimeout(() => this._refreshStatusOnly(), 2500); setTimeout(() => this._refreshStatusOnly(), 7000); + setTimeout(() => this._refreshStatusOnly(), 15000); } catch (e) { - alert(`Restart failed: ${e.message}`); + if (window.notificationSystem?.show) { + window.notificationSystem.show(`Restart of ${serviceName} failed: ${e.message}`, 'error'); + } } finally { setTimeout(() => { btn.disabled = false; diff --git a/containers/libreportal/frontend/core/tasks/js/task-actions.js b/containers/libreportal/frontend/core/tasks/js/task-actions.js index 465bcee..8ef2053 100755 --- a/containers/libreportal/frontend/core/tasks/js/task-actions.js +++ b/containers/libreportal/frontend/core/tasks/js/task-actions.js @@ -98,6 +98,25 @@ class TaskActions { } } + /** + * Restart a single compose service of an application (Services tab row + * button). Same locked-down CLI as the whole-app restart, scoped to one + * service — runs as a background task, no page switch. + */ + async restartService(appName, serviceName) { + try { + this.commands.validateCommand('service_restart', { appName, service: serviceName }); + return await this.executeTask( + 'service_restart', + appName, + `libreportal app restart ${appName} ${serviceName}`, + `Restart ${serviceName}` + ); + } catch (error) { + throw new Error(`Failed to restart ${serviceName}: ${error.message}`); + } + } + /** * Start an application */ diff --git a/containers/libreportal/frontend/core/tasks/js/task-commands.js b/containers/libreportal/frontend/core/tasks/js/task-commands.js index c6a076e..cd51510 100755 --- a/containers/libreportal/frontend/core/tasks/js/task-commands.js +++ b/containers/libreportal/frontend/core/tasks/js/task-commands.js @@ -10,6 +10,8 @@ class TaskCommands { install: 'libreportal app install {appName} {config}', uninstall: 'libreportal app uninstall {appName}', restart: 'libreportal app restart {appName}', + // Restart a single compose service of an app (Services tab row button). + service_restart: 'libreportal app restart {appName} {service}', start: 'libreportal app start {appName}', stop: 'libreportal app stop {appName}', backup: 'libreportal app backup {appName}', @@ -45,6 +47,7 @@ class TaskCommands { install: 'implemented', uninstall: 'implemented', restart: 'implemented', + service_restart: 'implemented', start: 'implemented', stop: 'implemented', backup: 'implemented', @@ -139,6 +142,7 @@ class TaskCommands { install: ['appName'], // config is optional uninstall: ['appName'], restart: ['appName'], + service_restart: ['appName', 'service'], start: ['appName'], stop: ['appName'], backup: ['appName'], diff --git a/containers/libreportal/frontend/core/tasks/js/task-router.js b/containers/libreportal/frontend/core/tasks/js/task-router.js index a3bd900..c94ac2c 100755 --- a/containers/libreportal/frontend/core/tasks/js/task-router.js +++ b/containers/libreportal/frontend/core/tasks/js/task-router.js @@ -32,7 +32,10 @@ class TaskRouter { case 'restart': return await this.actions.restartApp(params.appName); - + + case 'service_restart': + return await this.actions.restartService(params.appName, params.service); + case 'start': return await this.actions.startApp(params.appName); diff --git a/scripts/cli/commands/app/cli_app_commands.sh b/scripts/cli/commands/app/cli_app_commands.sh index 0ec6a6d..da8bc04 100755 --- a/scripts/cli/commands/app/cli_app_commands.sh +++ b/scripts/cli/commands/app/cli_app_commands.sh @@ -95,10 +95,13 @@ cliHandleAppCommands() ;; "restart") + # Optional 4th arg = one compose service to restart instead of the + # whole app (the WebUI Services tab routes per-service restarts + # here): libreportal app restart [service] if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then - dockerRestartApp "$app_name" + dockerRestartApp "$app_name" "$config" else - cliTaskRun "libreportal app restart $app_name" "restart" "$app_name" + cliTaskRun "libreportal app restart $app_name${config:+ $config}" "restart" "$app_name" fi ;; diff --git a/scripts/cli/commands/app/cli_app_header.sh b/scripts/cli/commands/app/cli_app_header.sh index 15662eb..d7db419 100755 --- a/scripts/cli/commands/app/cli_app_header.sh +++ b/scripts/cli/commands/app/cli_app_header.sh @@ -20,7 +20,8 @@ cliShowAppHelp() echo "" echo " libreportal app start [name*] - Start the specified app (Must be installed)" echo " libreportal app stop [name*] - Stop the specified app (Must be installed)" - echo " libreportal app restart [name*] - Restart the specified app (Must be installed)" + echo " libreportal app restart [name*] [service] - Restart the specified app, or just one of its" + echo " compose services when [service] is given" echo "" echo " libreportal app up [name*] - Docker-Compose up (Rebuild app)" echo " libreportal app down [name*] - Docker-Compose up (Uninstall app)" diff --git a/scripts/docker/app/docker/restart_app.sh b/scripts/docker/app/docker/restart_app.sh index e729f9e..8217c92 100755 --- a/scripts/docker/app/docker/restart_app.sh +++ b/scripts/docker/app/docker/restart_app.sh @@ -1,11 +1,34 @@ #!/bin/bash -dockerRestartApp() +dockerRestartApp() { local app_name="$1" + local service_name="$2" # optional: restart just this one compose service if [[ -z "$app_name" ]]; then isNotice "No app name provided. Unable to restart containers." + return 1 + fi + + # Single-service restart — the WebUI Services tab's per-row button routes + # here as a task (libreportal app restart ). Uses compose so + # the restart respects the deployed service definition, and dockerCommandRun + # so it runs as the right user in rootless mode. + if [[ -n "$service_name" ]]; then + # The name reaches a shell line — accept only compose-legal names. + if [[ ! "$service_name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then + isError "Invalid service name: $service_name" + return 1 + fi + local app_dir="${containers_dir%/}/$app_name" + if [[ ! -f "$app_dir/docker-compose.yml" ]]; then + isError "No compose file for '$app_name' at $app_dir/docker-compose.yml" + return 1 + fi + isNotice "Restarting service '$service_name' of '$app_name'. Please wait..." + local result; result=$(dockerCommandRun "cd $app_dir && docker compose restart $service_name" 2>&1) + checkSuccess "Restarted service '$service_name' of '$app_name'" + return fi isNotice "Restarting Docker containers for '$app_name'. Please wait..."