From 76b0ab754bae022e0e3fee041f781f3db49dee68 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Mon, 27 Apr 2026 03:52:24 +0900 Subject: [PATCH] Add dashboard health restart action (#38) --- apps/dashboard/src/App.tsx | 103 ++++++++++++++- apps/dashboard/src/api.ts | 29 +++++ apps/dashboard/src/i18n.ts | 50 ++++++++ apps/dashboard/src/styles.css | 120 ++++++++++++++++- src/web-dashboard-server.test.ts | 156 ++++++++++++++++++++++ src/web-dashboard-server.ts | 214 +++++++++++++++++++++++++++++++ 6 files changed, 670 insertions(+), 2 deletions(-) diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index 127a82c..703aa54 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -13,6 +13,7 @@ import { createScheduledTask, fetchDashboardData, runInboxAction, + runServiceAction, runScheduledTaskAction, sendRoomMessage, updateScheduledTask, @@ -48,6 +49,7 @@ type TaskActionKey = | `${string}:edit` | `${string}:${DashboardTaskAction}`; type InboxActionKey = `${string}:${DashboardInboxAction}`; +type ServiceActionKey = 'stack:restart'; type InboxFilter = 'all' | InboxItem['kind']; type HealthLevel = 'ok' | 'stale' | 'down'; @@ -913,13 +915,18 @@ function InboxPanel({ function HealthPanel({ data, locale, + onRestartStack, + serviceActionKey, t, }: { data: DashboardState; locale: Locale; + onRestartStack: () => void; + serviceActionKey: ServiceActionKey | null; t: Messages; }) { const services = data.overview.services; + const restarts = data.overview.operations?.serviceRestarts ?? []; const serviceLevels = services.map((service) => ({ service, level: serviceHealthLevel(service, data.overview.generatedAt), @@ -981,6 +988,71 @@ function HealthPanel({ +
+
+ {t.health.restart} + {t.health.restartStack} + {t.health.restartHint} +
+ +
+ + {restarts.length > 0 ? ( +
+ + {t.health.restartLog} + {restarts.length} + +
+ {restarts.map((restart) => { + const pill = + restart.status === 'success' + ? 'ok' + : restart.status === 'failed' + ? 'error' + : 'stale'; + return ( +
+
+ {t.health.restartTarget} + {restart.target} +
+ + {restart.status} + +
+ {t.health.restartRequested} + {formatDate(restart.requestedAt, locale)} +
+
+ {t.health.restartServices} + + {restart.services.length > 0 + ? restart.services.join(', ') + : '-'} + +
+ {restart.error ? ( +

{restart.error}

+ ) : null} +
+ ); + })} +
+
+ ) : null} + {services.length === 0 ? ( {t.service.empty} ) : affectedServices.length === 0 ? null : ( @@ -1705,6 +1777,8 @@ function App() { const [inboxActionKey, setInboxActionKey] = useState( null, ); + const [serviceActionKey, setServiceActionKey] = + useState(null); const [roomMessageKey, setRoomMessageKey] = useState(null); const t = messages[locale]; @@ -1809,6 +1883,27 @@ function App() { } } + async function handleServiceRestart() { + if ( + typeof window !== 'undefined' && + !window.confirm(t.health.confirmRestart) + ) { + return; + } + + setServiceActionKey('stack:restart'); + try { + await runServiceAction('stack', 'restart', { + requestId: makeClientRequestId(), + }); + await refresh(false); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setServiceActionKey(null); + } + } + async function handleRoomMessage( roomJid: string, text: string, @@ -1946,7 +2041,13 @@ function App() {

{t.panels.health}

{t.panels.healthSignals} - + void handleServiceRestart()} + serviceActionKey={serviceActionKey} + t={t} + /> ) : null} diff --git a/apps/dashboard/src/api.ts b/apps/dashboard/src/api.ts index 271e952..34021a2 100644 --- a/apps/dashboard/src/api.ts +++ b/apps/dashboard/src/api.ts @@ -35,6 +35,9 @@ export interface DashboardOverview { }>; fetchedAt: string | null; }; + operations?: { + serviceRestarts: DashboardServiceRestart[]; + }; inbox: Array<{ id: string; groupKey: string; @@ -62,6 +65,16 @@ export interface DashboardOverview { }>; } +export interface DashboardServiceRestart { + id: string; + target: 'stack'; + requestedAt: string; + completedAt: string | null; + status: 'running' | 'success' | 'failed'; + services: string[]; + error?: string; +} + export interface StatusSnapshot { serviceId: string; agentType: string; @@ -102,6 +115,7 @@ export interface DashboardTask { export type DashboardTaskAction = 'pause' | 'resume' | 'cancel'; export type DashboardInboxAction = 'run' | 'decline' | 'dismiss'; +export type DashboardServiceAction = 'restart'; export type DashboardTaskScheduleType = 'cron' | 'interval' | 'once'; export type DashboardTaskContextMode = 'group' | 'isolated'; @@ -232,6 +246,21 @@ export async function runInboxAction( }); } +export async function runServiceAction( + serviceId: 'stack', + action: DashboardServiceAction, + options: { requestId?: string } = {}, +): Promise<{ + ok: true; + duplicate?: boolean; + restart: DashboardServiceRestart; +}> { + return postJson(`/api/services/${encodeURIComponent(serviceId)}/actions`, { + action, + requestId: options.requestId, + }); +} + export async function sendRoomMessage( roomJid: string, text: string, diff --git a/apps/dashboard/src/i18n.ts b/apps/dashboard/src/i18n.ts index e864818..618ad93 100644 --- a/apps/dashboard/src/i18n.ts +++ b/apps/dashboard/src/i18n.ts @@ -72,6 +72,16 @@ export interface Messages { queue: string; ciFailures: string; affectedServices: string; + restart: string; + restartStack: string; + restarting: string; + restartHint: string; + confirmRestart: string; + restartLog: string; + restartTarget: string; + restartStatus: string; + restartRequested: string; + restartServices: string; levels: { ok: string; stale: string; @@ -323,6 +333,16 @@ export const messages = { queue: '큐', ciFailures: 'CI 실패', affectedServices: '이상 서비스', + restart: 'Restart', + restartStack: 'Restart stack', + restarting: 'Restarting...', + restartHint: '서비스 전체 재시작', + confirmRestart: 'Restart stack now?', + restartLog: 'Restart log', + restartTarget: 'Target', + restartStatus: 'Status', + restartRequested: 'Requested', + restartServices: 'Services', levels: { ok: '정상', stale: '주의', @@ -558,6 +578,16 @@ export const messages = { queue: 'Queue', ciFailures: 'CI failures', affectedServices: 'Affected services', + restart: 'Restart', + restartStack: 'Restart stack', + restarting: 'Restarting...', + restartHint: 'Restart all services', + confirmRestart: 'Restart stack now?', + restartLog: 'Restart log', + restartTarget: 'Target', + restartStatus: 'Status', + restartRequested: 'Requested', + restartServices: 'Services', levels: { ok: 'OK', stale: 'Watch', @@ -793,6 +823,16 @@ export const messages = { queue: '队列', ciFailures: 'CI 失败', affectedServices: '异常服务', + restart: '重启', + restartStack: '重启 stack', + restarting: '重启中...', + restartHint: '重启全部服务', + confirmRestart: '现在重启 stack?', + restartLog: '重启记录', + restartTarget: '目标', + restartStatus: '状态', + restartRequested: '请求', + restartServices: '服务', levels: { ok: '正常', stale: '关注', @@ -1028,6 +1068,16 @@ export const messages = { queue: 'キュー', ciFailures: 'CI失敗', affectedServices: '異常サービス', + restart: '再起動', + restartStack: 'Stack再起動', + restarting: '再起動中...', + restartHint: '全サービス再起動', + confirmRestart: 'Stackを再起動しますか?', + restartLog: '再起動ログ', + restartTarget: '対象', + restartStatus: '状態', + restartRequested: '要求', + restartServices: 'サービス', levels: { ok: '正常', stale: '注意', diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css index 13061ec..af84265 100644 --- a/apps/dashboard/src/styles.css +++ b/apps/dashboard/src/styles.css @@ -851,9 +851,12 @@ progress::-moz-progress-bar { .inbox-summary div, .health-signals div, .health-overview, +.health-actions, .inbox-card, .health-service, -.health-service-details { +.health-service-details, +.health-restart-log, +.health-restart-record { border: 1px solid rgba(43, 55, 38, 0.1); border-radius: 18px; background: rgba(255, 250, 240, 0.64); @@ -1018,6 +1021,100 @@ progress::-moz-progress-bar { letter-spacing: -0.04em; } +.health-actions { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; + padding: 12px; +} + +.health-actions > div { + display: grid; + min-width: 0; + gap: 3px; +} + +.health-actions strong, +.health-actions small { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.health-actions small { + color: var(--muted); +} + +.health-actions button { + min-height: 44px; + flex: none; +} + +.health-restart-log { + padding: 10px; +} + +.health-restart-log summary { + display: flex; + min-height: 34px; + cursor: pointer; + align-items: center; + justify-content: space-between; + color: var(--bg-ink); + font-size: 13px; + font-weight: 900; + list-style: none; +} + +.health-restart-log summary::-webkit-details-marker { + display: none; +} + +.health-restart-log[open] summary { + margin-bottom: 8px; +} + +.health-restart-list { + display: grid; + gap: 8px; +} + +.health-restart-record { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) minmax(0, 1fr); + gap: 10px; + align-items: center; + padding: 12px; +} + +.health-restart-record > div { + min-width: 0; +} + +.health-restart-record small, +.health-restart-record strong { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.health-restart-record small { + color: var(--muted); +} + +.health-restart-error { + grid-column: 1 / -1; + margin: 0; + color: #7c2518; + overflow-wrap: anywhere; + font-size: 12px; + font-weight: 800; +} + .health-down { border-color: rgba(183, 71, 52, 0.32); } @@ -1823,6 +1920,23 @@ progress::-moz-progress-bar { gap: 8px; } + .health-actions { + display: grid; + } + + .health-actions button { + width: 100%; + } + + .health-restart-record { + grid-template-columns: minmax(0, 1fr) auto; + } + + .health-restart-record > div:nth-of-type(2), + .health-restart-record > div:nth-of-type(3) { + grid-column: 1 / -1; + } + .health-service { grid-template-columns: minmax(0, 1fr) auto; } @@ -1930,6 +2044,10 @@ progress::-moz-progress-bar { grid-template-columns: 1fr; } + .health-restart-record { + grid-template-columns: 1fr; + } + .task-time-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } diff --git a/src/web-dashboard-server.test.ts b/src/web-dashboard-server.test.ts index 1fe99cb..e40cc5d 100644 --- a/src/web-dashboard-server.test.ts +++ b/src/web-dashboard-server.test.ts @@ -1019,6 +1019,162 @@ describe('web dashboard server handler', () => { expect(noQueue.status).toBe(503); }); + it('restarts the service stack through the health action endpoint', async () => { + let restartCalls = 0; + const handler = createWebDashboardHandler({ + readStatusSnapshots: () => [], + getTasks: () => [], + getPairedTasks: () => [], + restartServiceStack: () => { + restartCalls += 1; + return ['ejclaw']; + }, + now: () => '2026-04-26T05:30:00.000Z', + }); + const restart = () => + handler( + new Request('http://localhost/api/services/stack/actions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + action: 'restart', + requestId: 'stack-restart-1', + }), + }), + ); + + const response = await restart(); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + ok: true, + restart: { + id: 'web-restart-stack-restart-1', + target: 'stack', + requestedAt: '2026-04-26T05:30:00.000Z', + completedAt: '2026-04-26T05:30:00.000Z', + status: 'success', + services: ['ejclaw'], + }, + }); + expect(restartCalls).toBe(1); + + const duplicate = await restart(); + expect(duplicate.status).toBe(200); + await expect(duplicate.json()).resolves.toMatchObject({ + ok: true, + duplicate: true, + restart: { + id: 'web-restart-stack-restart-1', + status: 'success', + }, + }); + expect(restartCalls).toBe(1); + + const overview = await handler( + new Request('http://localhost/api/overview'), + ); + expect(overview.status).toBe(200); + await expect(overview.json()).resolves.toMatchObject({ + operations: { + serviceRestarts: [ + { + id: 'web-restart-stack-restart-1', + status: 'success', + services: ['ejclaw'], + }, + ], + }, + }); + }); + + it('records failed service stack restarts', async () => { + const handler = createWebDashboardHandler({ + readStatusSnapshots: () => [], + getTasks: () => [], + getPairedTasks: () => [], + restartServiceStack: () => { + throw new Error('systemctl failed'); + }, + now: () => '2026-04-26T05:35:00.000Z', + }); + + const response = await handler( + new Request('http://localhost/api/services/stack/actions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + action: 'restart', + requestId: 'stack-restart-fail', + }), + }), + ); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toMatchObject({ + error: 'systemctl failed', + restart: { + id: 'web-restart-stack-restart-fail', + target: 'stack', + status: 'failed', + error: 'systemctl failed', + }, + }); + + const overview = await handler( + new Request('http://localhost/api/overview'), + ); + await expect(overview.json()).resolves.toMatchObject({ + operations: { + serviceRestarts: [ + { + id: 'web-restart-stack-restart-fail', + status: 'failed', + error: 'systemctl failed', + }, + ], + }, + }); + }); + + it('rejects invalid service restart requests', async () => { + let restartCalls = 0; + const handler = createWebDashboardHandler({ + readStatusSnapshots: () => [], + getTasks: () => [], + getPairedTasks: () => [], + restartServiceStack: () => { + restartCalls += 1; + return ['ejclaw']; + }, + }); + + const invalidAction = await handler( + new Request('http://localhost/api/services/stack/actions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action: 'stop' }), + }), + ); + expect(invalidAction.status).toBe(400); + + const invalidTarget = await handler( + new Request('http://localhost/api/services/ejclaw/actions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action: 'restart' }), + }), + ); + expect(invalidTarget.status).toBe(400); + + const wrongMethod = await handler( + new Request('http://localhost/api/services/stack/actions', { + method: 'GET', + }), + ); + expect(wrongMethod.status).toBe(405); + expect(restartCalls).toBe(0); + }); + it('injects room messages and queues room work from the web dashboard', async () => { const messages: NewMessage[] = []; const metadata: Array<{ diff --git a/src/web-dashboard-server.ts b/src/web-dashboard-server.ts index 16f0fcd..0d65537 100644 --- a/src/web-dashboard-server.ts +++ b/src/web-dashboard-server.ts @@ -1,5 +1,6 @@ import fs from 'fs'; import path from 'path'; +import { execFileSync } from 'child_process'; import { CronExpressionParser } from 'cron-parser'; @@ -39,7 +40,13 @@ import { const DEFAULT_STATUS_MAX_AGE_MS = 10 * 60 * 1000; const ROOM_MESSAGE_ID_CACHE_LIMIT = 500; +const SERVICE_RESTART_LOG_LIMIT = 20; +const STACK_RESTART_UNIT_NAME = 'ejclaw-stack-restart.service'; const WEB_TASK_PROMPT_MAX_LENGTH = 8000; +const MANAGED_SERVICE_CALLER_FALLBACK_MESSAGE = + 'Stack restart unit is not installed yet. Run setup service from an external shell before retrying from a managed EJClaw service.'; +const UNIT_NOT_FOUND_PATTERN = + /(Unit .* not found|Could not find the requested service|not-found)/i; type PairedFollowUpTask = Pick< PairedTask, @@ -54,6 +61,16 @@ type WebPairedFollowUpScheduler = (args: { enqueue: () => void; }) => boolean; +export interface ServiceRestartRecord { + id: string; + target: 'stack'; + requestedAt: string; + completedAt: string | null; + status: 'running' | 'success' | 'failed'; + services: string[]; + error?: string; +} + export interface WebDashboardHandlerOptions { staticDir?: string; statusMaxAgeMs?: number; @@ -113,6 +130,7 @@ export interface WebDashboardHandlerOptions { hasMessage?: (chatJid: string, id: string) => boolean; enqueueMessageCheck?: (chatJid: string, groupFolder: string) => void; nudgeScheduler?: () => void; + restartServiceStack?: () => string[]; now?: () => string; } @@ -194,6 +212,7 @@ function serveStaticFile(staticDir: string, pathname: string): Response { type TaskAction = 'pause' | 'resume' | 'cancel'; type InboxAction = 'run' | 'decline' | 'dismiss'; +type ServiceAction = 'restart'; interface InboxActionRequest { action: InboxAction; @@ -201,6 +220,11 @@ interface InboxActionRequest { lastOccurredAt: string | null; } +interface ServiceActionRequest { + action: ServiceAction; + requestId: string | null; +} + function parseTaskPath(pathname: string): string | null { const match = pathname.match(/^\/api\/tasks\/([^/]+)$/); if (!match) return null; @@ -241,6 +265,16 @@ function parseRoomMessagePath(pathname: string): string | null { } } +function parseServiceActionPath(pathname: string): string | null { + const match = pathname.match(/^\/api\/services\/([^/]+)\/actions$/); + if (!match) return null; + try { + return decodeURIComponent(match[1]); + } catch { + return null; + } +} + function parsePairedInboxTarget( inboxId: string, ): { taskId: string; status: PairedTask['status'] } | null { @@ -278,6 +312,10 @@ function isInboxAction(value: unknown): value is InboxAction { return value === 'run' || value === 'decline' || value === 'dismiss'; } +function isServiceAction(value: unknown): value is ServiceAction { + return value === 'restart'; +} + function isPairedTaskStatus(value: unknown): value is PairedTask['status'] { return ( value === 'active' || @@ -322,6 +360,24 @@ async function readInboxAction( } } +async function readServiceAction( + request: Request, +): Promise { + try { + const body = (await request.json()) as { + action?: unknown; + requestId?: unknown; + }; + if (!isServiceAction(body.action)) return null; + return { + action: body.action, + requestId: sanitizeServiceActionRequestId(body.requestId), + }; + } catch { + return null; + } +} + interface ScheduledTaskMutationBody { roomJid?: unknown; groupFolder?: unknown; @@ -464,6 +520,20 @@ function makeWebRunId(prefix: string): string { return `web-${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } +function sanitizeServiceActionRequestId(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed) return null; + const safe = trimmed.replace(/[^A-Za-z0-9._:-]/g, '-').slice(0, 120); + return safe || null; +} + +function makeServiceRestartId(requestId: string | null): string { + return requestId + ? `web-restart-${requestId}` + : `web-restart-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + function sanitizeInboxActionRequestId(value: unknown): string | null { if (typeof value !== 'string') return null; const trimmed = value.trim(); @@ -513,6 +583,58 @@ function pairedFollowUpIntentForStatus( return null; } +function isRoot(): boolean { + return typeof process.getuid === 'function' && process.getuid() === 0; +} + +function restartEjclawStackServices(): string[] { + if (process.platform !== 'linux') { + throw new Error('Service restart only supports Linux systemd services'); + } + + const services = ['ejclaw']; + const systemctlArgs = isRoot() ? [] : ['--user']; + + try { + execFileSync( + 'systemctl', + [...systemctlArgs, 'start', '--wait', STACK_RESTART_UNIT_NAME], + { stdio: 'ignore' }, + ); + } catch (error) { + const stderr = + error && typeof error === 'object' && 'stderr' in error + ? String((error as { stderr?: unknown }).stderr || '') + : ''; + const stdout = + error && typeof error === 'object' && 'stdout' in error + ? String((error as { stdout?: unknown }).stdout || '') + : ''; + const message = + error instanceof Error ? error.message : `${stdout}\n${stderr}`.trim(); + const combined = `${message}\n${stdout}\n${stderr}`; + if (!UNIT_NOT_FOUND_PATTERN.test(combined)) { + throw error; + } + if (process.env.SERVICE_ID) { + throw new Error(MANAGED_SERVICE_CALLER_FALLBACK_MESSAGE); + } + + execFileSync('systemctl', [...systemctlArgs, 'restart', ...services], { + stdio: 'ignore', + }); + for (const service of services) { + execFileSync( + 'systemctl', + [...systemctlArgs, 'is-active', '--quiet', service], + { stdio: 'ignore' }, + ); + } + } + + return services; +} + export function createWebDashboardHandler( opts: WebDashboardHandlerOptions = {}, ): (request: Request) => Response | Promise { @@ -534,10 +656,14 @@ export function createWebDashboardHandler( const messageExists = opts.hasMessage ?? hasMessage; const enqueueMessageCheck = opts.enqueueMessageCheck; const nudgeScheduler = opts.nudgeScheduler; + const restartServiceStack = + opts.restartServiceStack ?? restartEjclawStackServices; const statusMaxAgeMs = opts.statusMaxAgeMs ?? DEFAULT_STATUS_MAX_AGE_MS; const seenRoomMessageIds: string[] = []; const seenRoomMessageIdSet = new Set(); const dismissedInboxKeys = new Set(); + const recentServiceRestarts: ServiceRestartRecord[] = []; + const activeServiceRestartTargets = new Set(); function rememberRoomMessageId(id: string): boolean { if (seenRoomMessageIdSet.has(id)) return false; @@ -560,12 +686,20 @@ export function createWebDashboardHandler( ); } + function rememberServiceRestart(record: ServiceRestartRecord): void { + recentServiceRestarts.unshift(record); + if (recentServiceRestarts.length > SERVICE_RESTART_LOG_LIMIT) { + recentServiceRestarts.length = SERVICE_RESTART_LOG_LIMIT; + } + } + return async (request: Request): Promise => { const url = new URL(request.url); const actionTaskId = parseTaskActionPath(url.pathname); const taskId = parseTaskPath(url.pathname); const actionInboxId = parseInboxActionPath(url.pathname); const messageRoomJid = parseRoomMessagePath(url.pathname); + const actionServiceId = parseServiceActionPath(url.pathname); if (actionTaskId) { if (request.method !== 'POST') { @@ -815,6 +949,83 @@ export function createWebDashboardHandler( return jsonResponse({ ok: true, id, queued: true }); } + if (actionServiceId) { + if (request.method !== 'POST') { + return jsonResponse({ error: 'Method not allowed' }, { status: 405 }); + } + if (actionServiceId !== 'stack') { + return jsonResponse( + { error: 'Unsupported service restart target' }, + { status: 400 }, + ); + } + + const serviceRequest = await readServiceAction(request); + if (!serviceRequest) { + return jsonResponse( + { error: 'Invalid service action' }, + { status: 400 }, + ); + } + + const id = makeServiceRestartId(serviceRequest.requestId); + const previous = recentServiceRestarts.find((record) => record.id === id); + if (serviceRequest.requestId && previous) { + if (previous.status === 'failed') { + return jsonResponse( + { + error: previous.error ?? 'Service restart failed', + duplicate: true, + restart: previous, + }, + { status: 500 }, + ); + } + return jsonResponse({ + ok: true, + duplicate: true, + restart: previous, + }); + } + + if (activeServiceRestartTargets.has(actionServiceId)) { + return jsonResponse( + { error: 'Service restart is already running' }, + { status: 409 }, + ); + } + + const requestedAt = opts.now?.() ?? new Date().toISOString(); + const record: ServiceRestartRecord = { + id, + target: 'stack', + requestedAt, + completedAt: null, + status: 'running', + services: [], + }; + rememberServiceRestart(record); + activeServiceRestartTargets.add(actionServiceId); + + try { + const services = restartServiceStack(); + record.completedAt = opts.now?.() ?? new Date().toISOString(); + record.status = 'success'; + record.services = services; + return jsonResponse({ ok: true, restart: record }); + } catch (error) { + record.completedAt = opts.now?.() ?? new Date().toISOString(); + record.status = 'failed'; + record.error = error instanceof Error ? error.message : String(error); + return jsonResponse( + { error: record.error, restart: record }, + { status: 500 }, + ); + } finally { + activeServiceRestartTargets.delete(actionServiceId); + } + } + if (url.pathname === '/api/tasks' && request.method === 'POST') { if (!loadRoomBindings) { return jsonResponse( @@ -994,6 +1205,9 @@ export function createWebDashboardHandler( }); return jsonResponse({ ...overview, + operations: { + serviceRestarts: recentServiceRestarts, + }, inbox: overview.inbox.filter((item) => !isInboxItemDismissed(item)), }); }