feat(admin): add restart controls and valkey delete actions
This commit is contained in:
parent
de13a08a98
commit
4b9ae37ead
@ -7,6 +7,7 @@ import {
|
|||||||
Database,
|
Database,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
|
Loader2,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
ServerCog,
|
ServerCog,
|
||||||
@ -51,6 +52,7 @@ interface InventoryService extends ServiceCheck {
|
|||||||
management: 'docker' | 'vm';
|
management: 'docker' | 'vm';
|
||||||
exposure: 'internal' | 'public';
|
exposure: 'internal' | 'public';
|
||||||
port?: number;
|
port?: number;
|
||||||
|
restartable: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HostTool {
|
interface HostTool {
|
||||||
@ -120,6 +122,9 @@ export default function OpsPage() {
|
|||||||
const [activeTab, setActiveTab] = useState<'overview' | 'inventory' | 'valkey'>('overview');
|
const [activeTab, setActiveTab] = useState<'overview' | 'inventory' | 'valkey'>('overview');
|
||||||
const [valkeyPattern, setValkeyPattern] = useState('*');
|
const [valkeyPattern, setValkeyPattern] = useState('*');
|
||||||
const [valkeyLimit, setValkeyLimit] = useState('25');
|
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) => {
|
const fetchValkey = async (pattern = valkeyPattern, limit = valkeyLimit) => {
|
||||||
try {
|
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(() => {
|
useEffect(() => {
|
||||||
fetchStatus();
|
fetchStatus();
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
@ -228,6 +298,12 @@ export default function OpsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{actionMessage && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-4 text-sm">{actionMessage}</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex gap-2 border-b">
|
<div className="flex gap-2 border-b">
|
||||||
{[
|
{[
|
||||||
{ id: 'overview', label: 'Overview', icon: Activity },
|
{ id: 'overview', label: 'Overview', icon: Activity },
|
||||||
@ -396,6 +472,7 @@ export default function OpsPage() {
|
|||||||
<TableHead>Exposure</TableHead>
|
<TableHead>Exposure</TableHead>
|
||||||
<TableHead>Port</TableHead>
|
<TableHead>Port</TableHead>
|
||||||
<TableHead>Target</TableHead>
|
<TableHead>Target</TableHead>
|
||||||
|
<TableHead>Action</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@ -418,6 +495,24 @@ export default function OpsPage() {
|
|||||||
<TableCell className="max-w-[280px] break-all font-mono text-xs">
|
<TableCell className="max-w-[280px] break-all font-mono text-xs">
|
||||||
{service.target}
|
{service.target}
|
||||||
</TableCell>
|
</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>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@ -534,6 +629,16 @@ export default function OpsPage() {
|
|||||||
<Search className="mr-2 h-4 w-4" />
|
<Search className="mr-2 h-4 w-4" />
|
||||||
Inspect
|
Inspect
|
||||||
</Button>
|
</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>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
@ -559,12 +664,13 @@ export default function OpsPage() {
|
|||||||
<TableHead>TTL</TableHead>
|
<TableHead>TTL</TableHead>
|
||||||
<TableHead>Size</TableHead>
|
<TableHead>Size</TableHead>
|
||||||
<TableHead>Preview</TableHead>
|
<TableHead>Preview</TableHead>
|
||||||
|
<TableHead>Action</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{valkeyLoading && (
|
{valkeyLoading && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={5}>
|
<TableCell colSpan={6}>
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||||
Loading Valkey state...
|
Loading Valkey state...
|
||||||
@ -586,6 +692,20 @@ export default function OpsPage() {
|
|||||||
<TableCell className="max-w-[360px] break-all font-mono text-xs">
|
<TableCell className="max-w-[360px] break-all font-mono text-xs">
|
||||||
{item.preview ?? 'No preview'}
|
{item.preview ?? 'No preview'}
|
||||||
</TableCell>
|
</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>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</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 });
|
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;
|
management: InventorySource;
|
||||||
exposure: 'internal' | 'public';
|
exposure: 'internal' | 'public';
|
||||||
port?: number;
|
port?: number;
|
||||||
|
restartable: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HostTool {
|
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> {
|
async function checkHttpService(service: HttpServiceDefinition): Promise<ServiceCheck> {
|
||||||
const baseUrl = (service.env && process.env[service.env]) || service.default;
|
const baseUrl = (service.env && process.env[service.env]) || service.default;
|
||||||
const target = `${baseUrl}${service.path}`;
|
const target = `${baseUrl}${service.path}`;
|
||||||
@ -487,6 +506,7 @@ export async function collectInventoryServices(): Promise<InventoryService[]> {
|
|||||||
management: service.management,
|
management: service.management,
|
||||||
exposure: service.exposure,
|
exposure: service.exposure,
|
||||||
port: service.port,
|
port: service.port,
|
||||||
|
restartable: Boolean(RESTARTABLE_SERVICE_CONTAINERS[service.id]),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -348,6 +348,8 @@ services:
|
|||||||
- PLATFORM_SERVICE_URL=http://platform-service:4003
|
- PLATFORM_SERVICE_URL=http://platform-service:4003
|
||||||
- EXTRACTION_SERVICE_URL=http://extraction-service:4005
|
- EXTRACTION_SERVICE_URL=http://extraction-service:4005
|
||||||
- SEED_SECRET=${SEED_SECRET:-dev-seed-secret}
|
- SEED_SECRET=${SEED_SECRET:-dev-seed-secret}
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
depends_on:
|
depends_on:
|
||||||
platform-service:
|
platform-service:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user