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..."