Merge claude/1

This commit is contained in:
librelad 2026-06-12 23:26:40 +01:00
commit 0d10284203
8 changed files with 81 additions and 73 deletions

View File

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

View File

@ -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;

View File

@ -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
*/ */

View File

@ -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'],

View File

@ -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);

View File

@ -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
;; ;;

View File

@ -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)"

View File

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