Extract dashboard service routes
This commit is contained in:
@@ -47,11 +47,14 @@ import {
|
||||
sanitizeScheduledTask,
|
||||
} from './web-dashboard-data.js';
|
||||
import { handleSimpleGetRoute } from './web-dashboard-routes.js';
|
||||
import {
|
||||
handleServiceRoute,
|
||||
type ServiceRestartRecord,
|
||||
} from './web-dashboard-service-routes.js';
|
||||
import { handleSettingsRoute } from './web-dashboard-settings-routes.js';
|
||||
|
||||
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 =
|
||||
@@ -72,15 +75,7 @@ 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 type { ServiceRestartRecord } from './web-dashboard-service-routes.js';
|
||||
|
||||
export interface WebDashboardHandlerOptions {
|
||||
staticDir?: string;
|
||||
@@ -258,7 +253,6 @@ function serveStaticFile(staticDir: string, pathname: string): Response {
|
||||
|
||||
type TaskAction = 'pause' | 'resume' | 'cancel';
|
||||
type InboxAction = 'run' | 'decline' | 'dismiss';
|
||||
type ServiceAction = 'restart';
|
||||
|
||||
interface InboxActionRequest {
|
||||
action: InboxAction;
|
||||
@@ -266,11 +260,6 @@ 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;
|
||||
@@ -321,16 +310,6 @@ function parseRoomTimelinePath(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 {
|
||||
@@ -368,10 +347,6 @@ 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' ||
|
||||
@@ -416,24 +391,6 @@ async function readInboxAction(
|
||||
}
|
||||
}
|
||||
|
||||
async function readServiceAction(
|
||||
request: Request,
|
||||
): Promise<ServiceActionRequest | null> {
|
||||
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;
|
||||
@@ -585,20 +542,6 @@ 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();
|
||||
@@ -862,13 +805,6 @@ 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<Response> => {
|
||||
const url = new URL(request.url);
|
||||
const actionTaskId = parseTaskActionPath(url.pathname);
|
||||
@@ -876,7 +812,6 @@ export function createWebDashboardHandler(
|
||||
const actionInboxId = parseInboxActionPath(url.pathname);
|
||||
const messageRoomJid = parseRoomMessagePath(url.pathname);
|
||||
const timelineRoomJid = parseRoomTimelinePath(url.pathname);
|
||||
const actionServiceId = parseServiceActionPath(url.pathname);
|
||||
|
||||
if (actionTaskId) {
|
||||
if (request.method !== 'POST') {
|
||||
@@ -1168,82 +1103,16 @@ export function createWebDashboardHandler(
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
const serviceRoute = await handleServiceRoute({
|
||||
url,
|
||||
request,
|
||||
jsonResponse,
|
||||
recentServiceRestarts,
|
||||
activeServiceRestartTargets,
|
||||
restartServiceStack,
|
||||
now: opts.now,
|
||||
});
|
||||
}
|
||||
|
||||
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 (serviceRoute) return serviceRoute;
|
||||
|
||||
const settingsRoute = await handleSettingsRoute({
|
||||
url,
|
||||
|
||||
172
src/web-dashboard-service-routes.test.ts
Normal file
172
src/web-dashboard-service-routes.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
handleServiceRoute,
|
||||
type ServiceRestartRecord,
|
||||
} from './web-dashboard-service-routes.js';
|
||||
|
||||
function jsonResponse(value: unknown, init?: ResponseInit): Response {
|
||||
return new Response(JSON.stringify(value), {
|
||||
...init,
|
||||
headers: {
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
...(init?.headers as Record<string, string> | undefined),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function request(
|
||||
pathname: string,
|
||||
method = 'POST',
|
||||
body?: Record<string, unknown>,
|
||||
): Request {
|
||||
return new Request(`http://localhost${pathname}`, {
|
||||
method,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
headers:
|
||||
body === undefined ? undefined : { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
async function route({
|
||||
activeServiceRestartTargets = new Set<string>(),
|
||||
body,
|
||||
method,
|
||||
now,
|
||||
pathname,
|
||||
recentServiceRestarts = [],
|
||||
restartServiceStack = () => ['ejclaw'],
|
||||
}: {
|
||||
activeServiceRestartTargets?: Set<string>;
|
||||
body?: Record<string, unknown>;
|
||||
method?: string;
|
||||
now?: () => string;
|
||||
pathname: string;
|
||||
recentServiceRestarts?: ServiceRestartRecord[];
|
||||
restartServiceStack?: () => string[];
|
||||
}): Promise<Response | null> {
|
||||
return handleServiceRoute({
|
||||
url: new URL(`http://localhost${pathname}`),
|
||||
request: request(pathname, method, body),
|
||||
jsonResponse,
|
||||
recentServiceRestarts,
|
||||
activeServiceRestartTargets,
|
||||
restartServiceStack,
|
||||
now,
|
||||
});
|
||||
}
|
||||
|
||||
describe('web dashboard service routes', () => {
|
||||
it('handles stack restarts and duplicate request ids', async () => {
|
||||
const recentServiceRestarts: ServiceRestartRecord[] = [];
|
||||
let restartCalls = 0;
|
||||
|
||||
const restart = async () =>
|
||||
route({
|
||||
pathname: '/api/services/stack/actions',
|
||||
body: { action: 'restart', requestId: 'stack-restart-1' },
|
||||
recentServiceRestarts,
|
||||
restartServiceStack: () => {
|
||||
restartCalls += 1;
|
||||
return ['ejclaw', 'reviewer'];
|
||||
},
|
||||
now: () => '2026-04-28T09:20:00.000Z',
|
||||
});
|
||||
|
||||
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-28T09:20:00.000Z',
|
||||
completedAt: '2026-04-28T09:20:00.000Z',
|
||||
status: 'success',
|
||||
services: ['ejclaw', 'reviewer'],
|
||||
},
|
||||
});
|
||||
expect(recentServiceRestarts).toHaveLength(1);
|
||||
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 unmatched = await route({ pathname: '/api/overview' });
|
||||
expect(unmatched).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects invalid service actions without restarting', async () => {
|
||||
const recentServiceRestarts: ServiceRestartRecord[] = [
|
||||
{
|
||||
id: 'web-restart-failed-request',
|
||||
target: 'stack',
|
||||
requestedAt: '2026-04-28T09:10:00.000Z',
|
||||
completedAt: '2026-04-28T09:10:01.000Z',
|
||||
status: 'failed',
|
||||
services: [],
|
||||
error: 'systemctl failed',
|
||||
},
|
||||
];
|
||||
let restartCalls = 0;
|
||||
const restartServiceStack = () => {
|
||||
restartCalls += 1;
|
||||
return ['ejclaw'];
|
||||
};
|
||||
|
||||
const active = await route({
|
||||
pathname: '/api/services/stack/actions',
|
||||
body: { action: 'restart' },
|
||||
activeServiceRestartTargets: new Set(['stack']),
|
||||
restartServiceStack,
|
||||
});
|
||||
expect(active?.status).toBe(409);
|
||||
|
||||
const invalidAction = await route({
|
||||
pathname: '/api/services/stack/actions',
|
||||
body: { action: 'stop' },
|
||||
restartServiceStack,
|
||||
});
|
||||
expect(invalidAction?.status).toBe(400);
|
||||
|
||||
const invalidTarget = await route({
|
||||
pathname: '/api/services/ejclaw/actions',
|
||||
body: { action: 'restart' },
|
||||
restartServiceStack,
|
||||
});
|
||||
expect(invalidTarget?.status).toBe(400);
|
||||
|
||||
const wrongMethod = await route({
|
||||
pathname: '/api/services/stack/actions',
|
||||
method: 'GET',
|
||||
restartServiceStack,
|
||||
});
|
||||
expect(wrongMethod?.status).toBe(405);
|
||||
|
||||
const failedDuplicate = await route({
|
||||
pathname: '/api/services/stack/actions',
|
||||
body: { action: 'restart', requestId: 'failed-request' },
|
||||
recentServiceRestarts,
|
||||
restartServiceStack,
|
||||
});
|
||||
expect(failedDuplicate?.status).toBe(500);
|
||||
await expect(failedDuplicate?.json()).resolves.toMatchObject({
|
||||
error: 'systemctl failed',
|
||||
duplicate: true,
|
||||
restart: {
|
||||
id: 'web-restart-failed-request',
|
||||
status: 'failed',
|
||||
},
|
||||
});
|
||||
expect(restartCalls).toBe(0);
|
||||
});
|
||||
});
|
||||
175
src/web-dashboard-service-routes.ts
Normal file
175
src/web-dashboard-service-routes.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
type JsonResponse = (
|
||||
value: unknown,
|
||||
init?: ResponseInit,
|
||||
request?: Request,
|
||||
) => Response;
|
||||
|
||||
type ServiceAction = 'restart';
|
||||
|
||||
interface ServiceActionRequest {
|
||||
action: ServiceAction;
|
||||
requestId: string | null;
|
||||
}
|
||||
|
||||
export interface ServiceRestartRecord {
|
||||
id: string;
|
||||
target: 'stack';
|
||||
requestedAt: string;
|
||||
completedAt: string | null;
|
||||
status: 'running' | 'success' | 'failed';
|
||||
services: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ServiceRouteContext {
|
||||
url: URL;
|
||||
request: Request;
|
||||
jsonResponse: JsonResponse;
|
||||
recentServiceRestarts: ServiceRestartRecord[];
|
||||
activeServiceRestartTargets: Set<string>;
|
||||
restartServiceStack: () => string[];
|
||||
now?: () => string;
|
||||
}
|
||||
|
||||
const SERVICE_RESTART_LOG_LIMIT = 20;
|
||||
|
||||
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 isServiceAction(value: unknown): value is ServiceAction {
|
||||
return value === 'restart';
|
||||
}
|
||||
|
||||
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)}`;
|
||||
}
|
||||
|
||||
async function readServiceAction(
|
||||
request: Request,
|
||||
): Promise<ServiceActionRequest | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function rememberServiceRestart(
|
||||
records: ServiceRestartRecord[],
|
||||
record: ServiceRestartRecord,
|
||||
): void {
|
||||
records.unshift(record);
|
||||
if (records.length > SERVICE_RESTART_LOG_LIMIT) {
|
||||
records.length = SERVICE_RESTART_LOG_LIMIT;
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
export async function handleServiceRoute({
|
||||
url,
|
||||
request,
|
||||
jsonResponse,
|
||||
recentServiceRestarts,
|
||||
activeServiceRestartTargets,
|
||||
restartServiceStack,
|
||||
now,
|
||||
}: ServiceRouteContext): Promise<Response | null> {
|
||||
const actionServiceId = parseServiceActionPath(url.pathname);
|
||||
if (!actionServiceId) return null;
|
||||
|
||||
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 = now?.() ?? new Date().toISOString();
|
||||
const record: ServiceRestartRecord = {
|
||||
id,
|
||||
target: 'stack',
|
||||
requestedAt,
|
||||
completedAt: null,
|
||||
status: 'running',
|
||||
services: [],
|
||||
};
|
||||
rememberServiceRestart(recentServiceRestarts, record);
|
||||
activeServiceRestartTargets.add(actionServiceId);
|
||||
|
||||
try {
|
||||
const services = restartServiceStack();
|
||||
record.completedAt = now?.() ?? new Date().toISOString();
|
||||
record.status = 'success';
|
||||
record.services = services;
|
||||
return jsonResponse({ ok: true, restart: record });
|
||||
} catch (err) {
|
||||
record.completedAt = now?.() ?? new Date().toISOString();
|
||||
record.status = 'failed';
|
||||
record.error = errorMessage(err);
|
||||
return jsonResponse(
|
||||
{ error: record.error, restart: record },
|
||||
{ status: 500 },
|
||||
);
|
||||
} finally {
|
||||
activeServiceRestartTargets.delete(actionServiceId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user