From 4b9ae37eade8016ecbac09ee18b1542b8667a3af Mon Sep 17 00:00:00 2001 From: root Date: Tue, 31 Mar 2026 08:44:25 +0000 Subject: [PATCH] feat(admin): add restart controls and valkey delete actions --- .../src/app/(dashboard)/ops/page.tsx | 122 +++++++++++++++++- .../src/app/api/ops/control/route.ts | 30 +++++ .../admin-web/src/app/api/ops/valkey/route.ts | 73 +++++++++++ .../admin-web/src/lib/docker-control.ts | 63 +++++++++ dashboards/admin-web/src/lib/ops-stack.ts | 20 +++ docker-compose.ecosystem.yml | 2 + 6 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 dashboards/admin-web/src/app/api/ops/control/route.ts create mode 100644 dashboards/admin-web/src/lib/docker-control.ts diff --git a/dashboards/admin-web/src/app/(dashboard)/ops/page.tsx b/dashboards/admin-web/src/app/(dashboard)/ops/page.tsx index 201a0c0d..e98714f1 100644 --- a/dashboards/admin-web/src/app/(dashboard)/ops/page.tsx +++ b/dashboards/admin-web/src/app/(dashboard)/ops/page.tsx @@ -7,6 +7,7 @@ import { Database, ExternalLink, HardDrive, + Loader2, RefreshCw, Search, ServerCog, @@ -51,6 +52,7 @@ interface InventoryService extends ServiceCheck { management: 'docker' | 'vm'; exposure: 'internal' | 'public'; port?: number; + restartable: boolean; } interface HostTool { @@ -120,6 +122,9 @@ export default function OpsPage() { const [activeTab, setActiveTab] = useState<'overview' | 'inventory' | 'valkey'>('overview'); const [valkeyPattern, setValkeyPattern] = useState('*'); const [valkeyLimit, setValkeyLimit] = useState('25'); + const [actionMessage, setActionMessage] = useState(null); + const [pendingRestart, setPendingRestart] = useState(null); + const [pendingDelete, setPendingDelete] = useState(null); const fetchValkey = async (pattern = valkeyPattern, limit = valkeyLimit) => { try { @@ -161,6 +166,71 @@ export default function OpsPage() { } }; + const restartService = async (serviceId: string) => { + try { + setPendingRestart(serviceId); + setActionMessage(null); + const res = await fetch('/api/ops/control', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'restart', serviceId }), + }); + const payload = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(payload.error || 'Restart failed'); + setActionMessage(`Restart requested for ${serviceId}`); + await fetchStatus(); + } catch (error) { + setActionMessage(error instanceof Error ? error.message : 'Restart failed'); + } finally { + setPendingRestart(null); + } + }; + + const deleteValkeyKey = async (key: string) => { + try { + setPendingDelete(key); + setActionMessage(null); + const res = await fetch('/api/ops/valkey', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'deleteKey', key }), + }); + const payload = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(payload.error || 'Delete failed'); + setActionMessage(`Deleted key ${key}`); + await fetchValkey(); + } catch (error) { + setActionMessage(error instanceof Error ? error.message : 'Delete failed'); + } finally { + setPendingDelete(null); + } + }; + + const deleteValkeyPattern = async () => { + try { + const marker = `pattern:${valkeyPattern}`; + setPendingDelete(marker); + setActionMessage(null); + const res = await fetch('/api/ops/valkey', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'deletePattern', + pattern: valkeyPattern, + limit: Number(valkeyLimit || '25'), + }), + }); + const payload = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(payload.error || 'Pattern delete failed'); + setActionMessage(`Deleted ${payload.deleted || 0} keys for pattern ${valkeyPattern}`); + await fetchValkey(); + } catch (error) { + setActionMessage(error instanceof Error ? error.message : 'Pattern delete failed'); + } finally { + setPendingDelete(null); + } + }; + useEffect(() => { fetchStatus(); const timer = setInterval(() => { @@ -228,6 +298,12 @@ export default function OpsPage() { )} + {actionMessage && ( + + {actionMessage} + + )} +
{[ { id: 'overview', label: 'Overview', icon: Activity }, @@ -396,6 +472,7 @@ export default function OpsPage() { Exposure Port Target + Action @@ -418,6 +495,24 @@ export default function OpsPage() { {service.target} + + {service.restartable ? ( + + ) : ( + n/a + )} + ))} @@ -534,6 +629,16 @@ export default function OpsPage() { Inspect +
@@ -559,12 +664,13 @@ export default function OpsPage() { TTL Size Preview + Action {valkeyLoading && ( - +
Loading Valkey state... @@ -586,6 +692,20 @@ export default function OpsPage() { {item.preview ?? 'No preview'} + + + ))} diff --git a/dashboards/admin-web/src/app/api/ops/control/route.ts b/dashboards/admin-web/src/app/api/ops/control/route.ts new file mode 100644 index 00000000..24621afb --- /dev/null +++ b/dashboards/admin-web/src/app/api/ops/control/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireAdmin } from '@/lib/auth-server'; +import { restartServiceContainer } from '@/lib/docker-control'; + +export const dynamic = 'force-dynamic'; + +export async function POST(req: NextRequest) { + try { + const admin = await requireAdmin(req); + if (!admin) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const body = await req.json().catch(() => null); + if (!body || body.action !== 'restart' || typeof body.serviceId !== 'string') { + return NextResponse.json({ error: 'Invalid control request' }, { status: 400 }); + } + + const result = await restartServiceContainer(body.serviceId); + return NextResponse.json({ + ok: true, + action: 'restart', + serviceId: body.serviceId, + container: result.container, + requestedAt: new Date().toISOString(), + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Control action failed'; + const status = message === 'Unauthorized' ? 401 : 500; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/dashboards/admin-web/src/app/api/ops/valkey/route.ts b/dashboards/admin-web/src/app/api/ops/valkey/route.ts index fa00e4e7..389f14b4 100644 --- a/dashboards/admin-web/src/app/api/ops/valkey/route.ts +++ b/dashboards/admin-web/src/app/api/ops/valkey/route.ts @@ -145,3 +145,76 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: message }, { status: message === 'Unauthorized' ? 401 : 500 }); } } + +export async function POST(req: NextRequest) { + try { + const admin = await requireAdmin(req); + if (!admin) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const body = await req.json().catch(() => null); + if (!body || typeof body.action !== 'string') { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }); + } + + const client = createClient({ + url: process.env.VALKEY_URL || 'redis://valkey:6379', + }); + + try { + await client.connect(); + + if (body.action === 'deleteKey') { + if (typeof body.key !== 'string' || !body.key.trim()) { + return NextResponse.json({ error: 'key is required' }, { status: 400 }); + } + + const deleted = await client.del(body.key.trim()); + return NextResponse.json({ + ok: true, + action: 'deleteKey', + key: body.key.trim(), + deleted, + }); + } + + if (body.action === 'deletePattern') { + const pattern = sanitizePattern(typeof body.pattern === 'string' ? body.pattern : null); + if (pattern === '*' || pattern.startsWith('*')) { + return NextResponse.json( + { error: 'Pattern delete requires a concrete prefix and cannot start with *' }, + { status: 400 } + ); + } + + const limit = Math.min(Math.max(Number(body.limit || 25), 1), 100); + const keys: string[] = []; + for await (const key of client.scanIterator({ + MATCH: pattern, + COUNT: Math.min(limit * 2, 200), + })) { + keys.push(key); + if (keys.length >= limit) break; + } + + const deleted = keys.length > 0 ? await client.del(keys) : 0; + return NextResponse.json({ + ok: true, + action: 'deletePattern', + pattern, + matched: keys.length, + deleted, + keys, + }); + } + + return NextResponse.json({ error: 'Unsupported action' }, { status: 400 }); + } finally { + if (client.isOpen) { + await client.quit(); + } + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Valkey write failed'; + return NextResponse.json({ error: message }, { status: message === 'Unauthorized' ? 401 : 500 }); + } +} diff --git a/dashboards/admin-web/src/lib/docker-control.ts b/dashboards/admin-web/src/lib/docker-control.ts new file mode 100644 index 00000000..d7cbfdf2 --- /dev/null +++ b/dashboards/admin-web/src/lib/docker-control.ts @@ -0,0 +1,63 @@ +import http from 'node:http'; +import { RESTARTABLE_SERVICE_CONTAINERS } from '@/lib/ops-stack'; + +const DOCKER_SOCKET_PATH = '/var/run/docker.sock'; + +function dockerRequest( + method: string, + path: string, + body?: string +): Promise<{ statusCode: number; body: string }> { + return new Promise((resolve, reject) => { + const req = http.request( + { + socketPath: DOCKER_SOCKET_PATH, + path, + method, + headers: body + ? { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + } + : undefined, + }, + res => { + let data = ''; + res.setEncoding('utf8'); + res.on('data', chunk => { + data += chunk; + }); + res.on('end', () => { + resolve({ + statusCode: res.statusCode ?? 500, + body: data, + }); + }); + } + ); + + req.on('error', reject); + if (body) { + req.write(body); + } + req.end(); + }); +} + +export function isRestartableService(serviceId: string): boolean { + return Boolean(RESTARTABLE_SERVICE_CONTAINERS[serviceId]); +} + +export async function restartServiceContainer(serviceId: string): Promise<{ container: string }> { + const container = RESTARTABLE_SERVICE_CONTAINERS[serviceId]; + if (!container) { + throw new Error('Service is not restartable from admin ops'); + } + + const response = await dockerRequest('POST', `/containers/${encodeURIComponent(container)}/restart?t=10`); + if (![204, 304].includes(response.statusCode)) { + throw new Error(response.body || `Docker restart failed with status ${response.statusCode}`); + } + + return { container }; +} diff --git a/dashboards/admin-web/src/lib/ops-stack.ts b/dashboards/admin-web/src/lib/ops-stack.ts index 4db35f81..54f2eebd 100644 --- a/dashboards/admin-web/src/lib/ops-stack.ts +++ b/dashboards/admin-web/src/lib/ops-stack.ts @@ -52,6 +52,7 @@ export interface InventoryService extends ServiceCheck { management: InventorySource; exposure: 'internal' | 'public'; port?: number; + restartable: boolean; } export interface HostTool { @@ -346,6 +347,24 @@ export const HOST_TOOLS: HostTool[] = [ }, ]; +export const RESTARTABLE_SERVICE_CONTAINERS: Record = { + 'admin-web': 'learning_ai_common_plat-admin-web-1', + 'tracker-web': 'learning_ai_common_plat-tracker-web-1', + platform: 'learning_ai_common_plat-platform-service-1', + extraction: 'learning_ai_common_plat-extraction-service-1', + mcp: 'learning_ai_common_plat-mcp-server-1', + grafana: 'learning_ai_common_plat-grafana-1', + loki: 'learning_ai_common_plat-loki-1', + prometheus: 'learning_ai_common_plat-prometheus-1', + 'node-exporter': 'learning_ai_common_plat-node-exporter-1', + cadvisor: 'learning_ai_common_plat-cadvisor-1', + valkey: 'learning_ai_common_plat-valkey-1', + 'gitea-registry': 'gitea-npm-registry', + mailpit: 'learning_ai_common_plat-mailpit-1', + azurite: 'learning_ai_common_plat-azurite-1', + 'cosmos-emulator': 'learning_ai_common_plat-cosmos-emulator-1', +}; + async function checkHttpService(service: HttpServiceDefinition): Promise { const baseUrl = (service.env && process.env[service.env]) || service.default; const target = `${baseUrl}${service.path}`; @@ -487,6 +506,7 @@ export async function collectInventoryServices(): Promise { management: service.management, exposure: service.exposure, port: service.port, + restartable: Boolean(RESTARTABLE_SERVICE_CONTAINERS[service.id]), }; }); } diff --git a/docker-compose.ecosystem.yml b/docker-compose.ecosystem.yml index 594fa28b..a5a522b4 100644 --- a/docker-compose.ecosystem.yml +++ b/docker-compose.ecosystem.yml @@ -348,6 +348,8 @@ services: - PLATFORM_SERVICE_URL=http://platform-service:4003 - EXTRACTION_SERVICE_URL=http://extraction-service:4005 - SEED_SECRET=${SEED_SECRET:-dev-seed-secret} + volumes: + - /var/run/docker.sock:/var/run/docker.sock depends_on: platform-service: condition: service_healthy