feat(admin): add restart controls and valkey delete actions

This commit is contained in:
root 2026-03-31 08:44:25 +00:00
parent de13a08a98
commit 4b9ae37ead
6 changed files with 309 additions and 1 deletions

View File

@ -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<string | null>(null);
const [pendingRestart, setPendingRestart] = useState<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<string | null>(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() {
</Card>
)}
{actionMessage && (
<Card>
<CardContent className="py-4 text-sm">{actionMessage}</CardContent>
</Card>
)}
<div className="flex gap-2 border-b">
{[
{ id: 'overview', label: 'Overview', icon: Activity },
@ -396,6 +472,7 @@ export default function OpsPage() {
<TableHead>Exposure</TableHead>
<TableHead>Port</TableHead>
<TableHead>Target</TableHead>
<TableHead>Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@ -418,6 +495,24 @@ export default function OpsPage() {
<TableCell className="max-w-[280px] break-all font-mono text-xs">
{service.target}
</TableCell>
<TableCell>
{service.restartable ? (
<Button
size="sm"
variant="outline"
onClick={() => restartService(service.id)}
disabled={pendingRestart === service.id}
>
{pendingRestart === service.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Restart'
)}
</Button>
) : (
<span className="text-xs text-muted-foreground">n/a</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
@ -534,6 +629,16 @@ export default function OpsPage() {
<Search className="mr-2 h-4 w-4" />
Inspect
</Button>
<Button
variant="destructive"
onClick={() => deleteValkeyPattern()}
disabled={pendingDelete === `pattern:${valkeyPattern}`}
>
{pendingDelete === `pattern:${valkeyPattern}` ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Delete Pattern
</Button>
</div>
<div className="grid gap-4 md:grid-cols-3">
@ -559,12 +664,13 @@ export default function OpsPage() {
<TableHead>TTL</TableHead>
<TableHead>Size</TableHead>
<TableHead>Preview</TableHead>
<TableHead>Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{valkeyLoading && (
<TableRow>
<TableCell colSpan={5}>
<TableCell colSpan={6}>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<RefreshCw className="h-4 w-4 animate-spin" />
Loading Valkey state...
@ -586,6 +692,20 @@ export default function OpsPage() {
<TableCell className="max-w-[360px] break-all font-mono text-xs">
{item.preview ?? 'No preview'}
</TableCell>
<TableCell>
<Button
size="sm"
variant="destructive"
onClick={() => deleteValkeyKey(item.key)}
disabled={pendingDelete === item.key}
>
{pendingDelete === item.key ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Delete'
)}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>

View File

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

View File

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

View File

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

View File

@ -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<string, string> = {
'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<ServiceCheck> {
const baseUrl = (service.env && process.env[service.env]) || service.default;
const target = `${baseUrl}${service.path}`;
@ -487,6 +506,7 @@ export async function collectInventoryServices(): Promise<InventoryService[]> {
management: service.management,
exposure: service.exposure,
port: service.port,
restartable: Boolean(RESTARTABLE_SERVICE_CONTAINERS[service.id]),
};
});
}

View File

@ -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