Files
EJClaw/src/web-dashboard-service-routes.ts
2026-04-28 18:19:42 +09:00

176 lines
4.5 KiB
TypeScript

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