feat(admin): add restart controls and valkey delete actions
This commit is contained in:
parent
de13a08a98
commit
4b9ae37ead
@ -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>
|
||||
|
||||
30
dashboards/admin-web/src/app/api/ops/control/route.ts
Normal file
30
dashboards/admin-web/src/app/api/ops/control/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
63
dashboards/admin-web/src/lib/docker-control.ts
Normal file
63
dashboards/admin-web/src/lib/docker-control.ts
Normal 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 };
|
||||
}
|
||||
@ -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]),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user