Compare commits
2 Commits
105644364f
...
0d10284203
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d10284203 | ||
|
|
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