fix(services): route per-service restart through the task system + CLI
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 <app> [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 <noreply@anthropic.com> Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
parent
105644364f
commit
a28eed0729
@ -11,9 +11,11 @@
|
|||||||
// directly over the unix socket. That means no extra system deps and
|
// directly over the unix socket. That means no extra system deps and
|
||||||
// no group-level privilege grants — node only sees what the mounted
|
// no group-level privilege grants — node only sees what the mounted
|
||||||
// socket lets it see.
|
// socket lets it see.
|
||||||
// - Restart still goes through the existing task system. The bash task
|
// - This file is READ-ONLY surface (status + log tails). Restarting a
|
||||||
// processor runs on the host (where `docker` IS available) so its
|
// service is a mutation, so it goes through the task system like every
|
||||||
// `docker compose restart …` command works fine.
|
// other mutation: the Services tab dispatches a `service_restart` task
|
||||||
|
// (core/tasks) that runs `libreportal app restart <app> <service>` 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
|
// - URLs / port chips for each service are read client-side from the
|
||||||
// existing /data/apps/generated/apps-services.json — no backend
|
// existing /data/apps/generated/apps-services.json — no backend
|
||||||
// surface needed for that.
|
// surface needed for that.
|
||||||
@ -25,18 +27,10 @@ const path = require('path');
|
|||||||
const http = require('http');
|
const http = require('http');
|
||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
const { requireAuth } = require('../utils/middleware.js');
|
const { requireAuth } = require('../utils/middleware.js');
|
||||||
const { pokeFifo } = require('../utils/fifo.js');
|
|
||||||
const { fileConfig } = require('../utils/config.js');
|
const { fileConfig } = require('../utils/config.js');
|
||||||
|
|
||||||
const router = express.Router();
|
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');
|
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' };
|
return { transport: 'docker' };
|
||||||
}
|
}
|
||||||
|
|
||||||
function appComposeFile(appName) {
|
|
||||||
return path.join(CONTAINERS_DIR, appName, 'docker-compose.yml');
|
|
||||||
}
|
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// GET /api/apps/:appName/services/status
|
// GET /api/apps/:appName/services/status
|
||||||
// → [{ serviceName, state, statusText, containerName, containerId }]
|
// → [{ 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 <service>` 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
|
// GET /api/apps/:appName/services/:serviceName/logs
|
||||||
// SSE-wraps the Docker /containers/<id>/logs?follow=1 stream.
|
// SSE-wraps the Docker /containers/<id>/logs?follow=1 stream.
|
||||||
|
|||||||
@ -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 <app> <service>) —
|
||||||
|
// 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) {
|
async _restartService(serviceName, btn) {
|
||||||
if (!this.currentApp) return;
|
if (!this.currentApp) return;
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.classList.add('is-running');
|
btn.classList.add('is-running');
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(
|
const route = window.LP?.services?.tasks?.route || window.tasksManager?.router;
|
||||||
`/api/apps/${encodeURIComponent(this.currentApp)}/services/${encodeURIComponent(serviceName)}/restart`,
|
if (route && typeof route.routeAction === 'function') {
|
||||||
{ method: 'POST', headers: { 'Content-Type': 'application/json' } }
|
await route.routeAction('service_restart', { appName: this.currentApp, service: serviceName });
|
||||||
);
|
} else if (typeof route === 'function') {
|
||||||
if (!resp.ok) {
|
await route('service_restart', { appName: this.currentApp, service: serviceName });
|
||||||
const err = await resp.json().catch(() => ({}));
|
} else {
|
||||||
throw new Error(err.error || `HTTP ${resp.status}`);
|
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(), 2500);
|
||||||
setTimeout(() => this._refreshStatusOnly(), 7000);
|
setTimeout(() => this._refreshStatusOnly(), 7000);
|
||||||
|
setTimeout(() => this._refreshStatusOnly(), 15000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(`Restart failed: ${e.message}`);
|
if (window.notificationSystem?.show) {
|
||||||
|
window.notificationSystem.show(`Restart of ${serviceName} failed: ${e.message}`, 'error');
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
|
|||||||
@ -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
|
* Start an application
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -10,6 +10,8 @@ class TaskCommands {
|
|||||||
install: 'libreportal app install {appName} {config}',
|
install: 'libreportal app install {appName} {config}',
|
||||||
uninstall: 'libreportal app uninstall {appName}',
|
uninstall: 'libreportal app uninstall {appName}',
|
||||||
restart: 'libreportal app restart {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}',
|
start: 'libreportal app start {appName}',
|
||||||
stop: 'libreportal app stop {appName}',
|
stop: 'libreportal app stop {appName}',
|
||||||
backup: 'libreportal app backup {appName}',
|
backup: 'libreportal app backup {appName}',
|
||||||
@ -45,6 +47,7 @@ class TaskCommands {
|
|||||||
install: 'implemented',
|
install: 'implemented',
|
||||||
uninstall: 'implemented',
|
uninstall: 'implemented',
|
||||||
restart: 'implemented',
|
restart: 'implemented',
|
||||||
|
service_restart: 'implemented',
|
||||||
start: 'implemented',
|
start: 'implemented',
|
||||||
stop: 'implemented',
|
stop: 'implemented',
|
||||||
backup: 'implemented',
|
backup: 'implemented',
|
||||||
@ -139,6 +142,7 @@ class TaskCommands {
|
|||||||
install: ['appName'], // config is optional
|
install: ['appName'], // config is optional
|
||||||
uninstall: ['appName'],
|
uninstall: ['appName'],
|
||||||
restart: ['appName'],
|
restart: ['appName'],
|
||||||
|
service_restart: ['appName', 'service'],
|
||||||
start: ['appName'],
|
start: ['appName'],
|
||||||
stop: ['appName'],
|
stop: ['appName'],
|
||||||
backup: ['appName'],
|
backup: ['appName'],
|
||||||
|
|||||||
@ -32,7 +32,10 @@ class TaskRouter {
|
|||||||
|
|
||||||
case 'restart':
|
case 'restart':
|
||||||
return await this.actions.restartApp(params.appName);
|
return await this.actions.restartApp(params.appName);
|
||||||
|
|
||||||
|
case 'service_restart':
|
||||||
|
return await this.actions.restartService(params.appName, params.service);
|
||||||
|
|
||||||
case 'start':
|
case 'start':
|
||||||
return await this.actions.startApp(params.appName);
|
return await this.actions.startApp(params.appName);
|
||||||
|
|
||||||
|
|||||||
@ -95,10 +95,13 @@ cliHandleAppCommands()
|
|||||||
;;
|
;;
|
||||||
|
|
||||||
"restart")
|
"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 <app> [service]
|
||||||
if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then
|
if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then
|
||||||
dockerRestartApp "$app_name"
|
dockerRestartApp "$app_name" "$config"
|
||||||
else
|
else
|
||||||
cliTaskRun "libreportal app restart $app_name" "restart" "$app_name"
|
cliTaskRun "libreportal app restart $app_name${config:+ $config}" "restart" "$app_name"
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
|
|
||||||
|
|||||||
@ -20,7 +20,8 @@ cliShowAppHelp()
|
|||||||
echo ""
|
echo ""
|
||||||
echo " libreportal app start [name*] - Start the specified app (Must be installed)"
|
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 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 ""
|
||||||
echo " libreportal app up [name*] - Docker-Compose up (Rebuild app)"
|
echo " libreportal app up [name*] - Docker-Compose up (Rebuild app)"
|
||||||
echo " libreportal app down [name*] - Docker-Compose up (Uninstall app)"
|
echo " libreportal app down [name*] - Docker-Compose up (Uninstall app)"
|
||||||
|
|||||||
@ -1,11 +1,34 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
dockerRestartApp()
|
dockerRestartApp()
|
||||||
{
|
{
|
||||||
local app_name="$1"
|
local app_name="$1"
|
||||||
|
local service_name="$2" # optional: restart just this one compose service
|
||||||
|
|
||||||
if [[ -z "$app_name" ]]; then
|
if [[ -z "$app_name" ]]; then
|
||||||
isNotice "No app name provided. Unable to restart containers."
|
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 <app> <service>). 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
|
fi
|
||||||
|
|
||||||
isNotice "Restarting Docker containers for '$app_name'. Please wait..."
|
isNotice "Restarting Docker containers for '$app_name'. Please wait..."
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user