feat(admin): add VM inventory and Valkey inspector

This commit is contained in:
root 2026-03-31 08:32:30 +00:00
parent 2cf557a2c8
commit bd7ebeb248
7 changed files with 1200 additions and 350 deletions

View File

@ -49,6 +49,7 @@
"react": "19.2.3",
"react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"redis": "^4.7.0",
"recharts": "^3.7.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.4.0"

View File

@ -1,13 +1,24 @@
'use client';
import { useEffect, useState } from 'react';
import { Activity, CheckCircle, ExternalLink, RefreshCw, ShieldAlert } from 'lucide-react';
import {
Activity,
CheckCircle,
Database,
ExternalLink,
HardDrive,
RefreshCw,
Search,
ServerCog,
ShieldAlert,
} from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Input } from '@/components/ui/input';
import {
Table,
TableBody,
@ -35,6 +46,59 @@ interface OpsStatus {
services: ServiceCheck[];
}
interface InventoryService extends ServiceCheck {
description: string;
management: 'docker' | 'vm';
exposure: 'internal' | 'public';
port?: number;
}
interface HostTool {
id: string;
name: string;
group: string;
source: 'docker' | 'vm';
management: string;
status: 'managed' | 'manual';
description: string;
}
interface InventoryData {
timestamp: string;
counts: {
services: number;
healthy: number;
degraded: number;
down: number;
hostTools: number;
};
services: InventoryService[];
hostTools: HostTool[];
}
interface ValkeyKey {
key: string;
type: string;
ttlSeconds: number;
size?: number;
preview?: string;
}
interface ValkeyData {
timestamp: string;
pattern: string;
limit: number;
summary: {
ping: string;
dbsize: number;
matchedKeys: number;
version: string;
usedMemoryHuman: string;
usedMemoryPeakHuman: string;
};
keys: ValkeyKey[];
}
const OPS_LINKS = [
{ label: 'Grafana', href: 'http://127.0.0.1:3000' },
{ label: 'Prometheus', href: 'http://127.0.0.1:9090' },
@ -46,20 +110,49 @@ const OPS_LINKS = [
export default function OpsPage() {
const [data, setData] = useState<OpsStatus | null>(null);
const [inventory, setInventory] = useState<InventoryData | null>(null);
const [valkey, setValkey] = useState<ValkeyData | null>(null);
const [loading, setLoading] = useState(true);
const [valkeyLoading, setValkeyLoading] = useState(true);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [_error, setError] = useState<string | null>(null);
const [countdown, setCountdown] = useState(10);
const [activeTab, setActiveTab] = useState<'overview' | 'inventory' | 'valkey'>('overview');
const [valkeyPattern, setValkeyPattern] = useState('*');
const [valkeyLimit, setValkeyLimit] = useState('25');
const fetchValkey = async (pattern = valkeyPattern, limit = valkeyLimit) => {
try {
setValkeyLoading(true);
const params = new URLSearchParams({
pattern,
limit,
});
const res = await fetch(`/api/ops/valkey?${params.toString()}`);
if (!res.ok) throw new Error('Failed to fetch Valkey state');
setValkey(await res.json());
} finally {
setValkeyLoading(false);
}
};
const fetchStatus = async () => {
try {
setLoading(true);
const res = await fetch('/api/ops/status');
if (!res.ok) throw new Error('Failed to fetch status');
const json = await res.json();
setData(json);
const [statusRes, inventoryRes] = await Promise.all([
fetch('/api/ops/status'),
fetch('/api/ops/inventory'),
]);
if (!statusRes.ok) throw new Error('Failed to fetch status');
if (!inventoryRes.ok) throw new Error('Failed to fetch inventory');
const [statusJson, inventoryJson] = await Promise.all([statusRes.json(), inventoryRes.json()]);
setData(statusJson);
setInventory(inventoryJson);
setLastUpdated(new Date());
setError(null);
await fetchValkey();
} catch (err) {
setError(String(err));
} finally {
@ -73,7 +166,7 @@ export default function OpsPage() {
const timer = setInterval(() => {
setCountdown(prev => {
if (prev <= 1) {
fetchStatus(); // trigger refresh
fetchStatus();
return 10;
}
return prev - 1;
@ -114,7 +207,6 @@ export default function OpsPage() {
</div>
</div>
{/* Global Status Banner */}
{data && (
<Card
className={`border-l-4 ${data.overall === 'healthy' ? 'border-l-green-500' : data.overall === 'degraded' ? 'border-l-yellow-500' : 'border-l-red-500'}`}
@ -136,91 +228,373 @@ export default function OpsPage() {
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Ops Links</CardTitle>
<CardDescription>Direct entry points for internal monitoring and health review.</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{OPS_LINKS.map(link => (
<a
key={link.label}
href={link.href}
target="_blank"
rel="noreferrer"
className="flex items-center justify-between rounded-lg border px-4 py-3 text-sm hover:bg-accent"
>
<span className="font-medium">{link.label}</span>
<ExternalLink className="h-4 w-4 text-muted-foreground" />
</a>
))}
</CardContent>
</Card>
{/* Service Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{data?.services.map(svc => (
<Card key={svc.id}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{svc.name}</CardTitle>
<Activity
className={`h-4 w-4 ${svc.status === 'healthy' ? 'text-muted-foreground' : 'text-red-500 animate-pulse'}`}
/>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between mb-2">
<Badge variant="outline" className={getStatusColor(svc.status)}>
{svc.status}
</Badge>
<div className={`text-sm font-mono font-bold ${getLatencyColor(svc.latency)}`}>
{svc.latency}ms
</div>
</div>
<div className="space-y-1 mt-3">
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Group</span>
<span className="font-medium">{svc.group}</span>
</div>
<div className="text-xs text-muted-foreground font-mono break-all">
{svc.target}
</div>
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Uptime (30d)</span>
<span className="font-medium">99.9%</span>
</div>
<Progress value={svc.status === 'down' ? 0 : 99} className="h-1" />
</div>
{svc.message && (
<div className="mt-3 rounded bg-muted p-2 text-xs font-mono text-destructive">
{svc.message}
</div>
)}
<div className="mt-3 text-xs text-muted-foreground flex justify-between">
<span>v{svc.version || '?'}</span>
<span>{new Date(svc.lastChecked).toLocaleTimeString()}</span>
</div>
</CardContent>
</Card>
<div className="flex gap-2 border-b">
{[
{ id: 'overview', label: 'Overview', icon: Activity },
{ id: 'inventory', label: 'VM Inventory', icon: ServerCog },
{ id: 'valkey', label: 'Valkey Inspector', icon: Database },
].map(({ id, label, icon: Icon }) => (
<button
key={id}
onClick={() => setActiveTab(id as typeof activeTab)}
className={`flex items-center gap-2 px-4 py-2 border-b-2 transition-colors ${
activeTab === id
? 'border-emerald-600 text-emerald-700'
: 'border-transparent hover:border-gray-300'
}`}
>
<Icon className="h-4 w-4" />
{label}
</button>
))}
{!data &&
loading &&
Array.from({ length: 5 }).map((_, i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-4 w-[150px]" />
</CardHeader>
<CardContent>
<Skeleton className="h-[100px] w-full" />
</CardContent>
</Card>
))}
</div>
{/* Dependency Matrix (Static for now) */}
{activeTab === 'overview' && (
<>
<Card>
<CardHeader>
<CardTitle>Ops Links</CardTitle>
<CardDescription>
Direct entry points for internal monitoring and health review.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{OPS_LINKS.map(link => (
<a
key={link.label}
href={link.href}
target="_blank"
rel="noreferrer"
className="flex items-center justify-between rounded-lg border px-4 py-3 text-sm hover:bg-accent"
>
<span className="font-medium">{link.label}</span>
<ExternalLink className="h-4 w-4 text-muted-foreground" />
</a>
))}
</CardContent>
</Card>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{data?.services.map(svc => (
<Card key={svc.id}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{svc.name}</CardTitle>
<Activity
className={`h-4 w-4 ${svc.status === 'healthy' ? 'text-muted-foreground' : 'text-red-500 animate-pulse'}`}
/>
</CardHeader>
<CardContent>
<div className="mb-2 flex items-center justify-between">
<Badge variant="outline" className={getStatusColor(svc.status)}>
{svc.status}
</Badge>
<div className={`text-sm font-mono font-bold ${getLatencyColor(svc.latency)}`}>
{svc.latency}ms
</div>
</div>
<div className="mt-3 space-y-1">
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Group</span>
<span className="font-medium">{svc.group}</span>
</div>
<div className="break-all text-xs font-mono text-muted-foreground">
{svc.target}
</div>
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Uptime (30d)</span>
<span className="font-medium">99.9%</span>
</div>
<Progress value={svc.status === 'down' ? 0 : 99} className="h-1" />
</div>
{svc.message && (
<div className="mt-3 rounded bg-muted p-2 text-xs font-mono text-destructive">
{svc.message}
</div>
)}
<div className="mt-3 flex justify-between text-xs text-muted-foreground">
<span>v{svc.version || '?'}</span>
<span>{new Date(svc.lastChecked).toLocaleTimeString()}</span>
</div>
</CardContent>
</Card>
))}
{!data &&
loading &&
Array.from({ length: 5 }).map((_, i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-4 w-[150px]" />
</CardHeader>
<CardContent>
<Skeleton className="h-[100px] w-full" />
</CardContent>
</Card>
))}
</div>
</>
)}
{activeTab === 'inventory' && (
<>
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Managed Services
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{inventory?.counts.services ?? 0}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Healthy</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-green-600">
{inventory?.counts.healthy ?? 0}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Down</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-red-600">{inventory?.counts.down ?? 0}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Host Tools
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{inventory?.counts.hostTools ?? 0}</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Service Inventory</CardTitle>
<CardDescription>Live Docker-managed stack reachable from the admin container.</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Service</TableHead>
<TableHead>Group</TableHead>
<TableHead>Status</TableHead>
<TableHead>Exposure</TableHead>
<TableHead>Port</TableHead>
<TableHead>Target</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{inventory?.services.map(service => (
<TableRow key={service.id}>
<TableCell>
<div className="font-medium">{service.name}</div>
<div className="text-xs text-muted-foreground">{service.description}</div>
</TableCell>
<TableCell>{service.group}</TableCell>
<TableCell>
<Badge variant="outline" className={getStatusColor(service.status)}>
{service.status}
</Badge>
</TableCell>
<TableCell>
<Badge variant="secondary">{service.exposure}</Badge>
</TableCell>
<TableCell>{service.port ?? 'n/a'}</TableCell>
<TableCell className="max-w-[280px] break-all font-mono text-xs">
{service.target}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>VM Tooling</CardTitle>
<CardDescription>Host-level tools and mounted config that support the stack.</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Tool</TableHead>
<TableHead>Group</TableHead>
<TableHead>Management</TableHead>
<TableHead>Status</TableHead>
<TableHead>Description</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{inventory?.hostTools.map(tool => (
<TableRow key={tool.id}>
<TableCell className="font-medium">{tool.name}</TableCell>
<TableCell>{tool.group}</TableCell>
<TableCell>{tool.management}</TableCell>
<TableCell>
<Badge variant={tool.status === 'managed' ? 'default' : 'secondary'}>
{tool.status}
</Badge>
</TableCell>
<TableCell>{tool.description}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</>
)}
{activeTab === 'valkey' && (
<>
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Ping</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{valkey?.summary.ping ?? '--'}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
DB Size
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{valkey?.summary.dbsize ?? 0}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Used Memory
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{valkey?.summary.usedMemoryHuman ?? '--'}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Peak Memory
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">
{valkey?.summary.usedMemoryPeakHuman ?? '--'}
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Key Explorer</CardTitle>
<CardDescription>
Read-only Valkey inspection for keys, TTLs, and small previews.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row">
<div className="flex-1">
<Input
value={valkeyPattern}
onChange={event => setValkeyPattern(event.target.value)}
placeholder="Pattern, e.g. extraction:*"
/>
</div>
<div className="w-full md:w-28">
<Input
value={valkeyLimit}
onChange={event => setValkeyLimit(event.target.value)}
placeholder="25"
/>
</div>
<Button onClick={() => fetchValkey()}>
<Search className="mr-2 h-4 w-4" />
Inspect
</Button>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="rounded-lg border p-4">
<div className="mb-1 text-sm text-muted-foreground">Version</div>
<div className="font-semibold">{valkey?.summary.version ?? '--'}</div>
</div>
<div className="rounded-lg border p-4">
<div className="mb-1 text-sm text-muted-foreground">Pattern</div>
<div className="font-mono text-sm">{valkey?.pattern ?? '*'}</div>
</div>
<div className="rounded-lg border p-4">
<div className="mb-1 text-sm text-muted-foreground">Matched</div>
<div className="font-semibold">{valkey?.summary.matchedKeys ?? 0}</div>
</div>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Key</TableHead>
<TableHead>Type</TableHead>
<TableHead>TTL</TableHead>
<TableHead>Size</TableHead>
<TableHead>Preview</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{valkeyLoading && (
<TableRow>
<TableCell colSpan={5}>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<RefreshCw className="h-4 w-4 animate-spin" />
Loading Valkey state...
</div>
</TableCell>
</TableRow>
)}
{!valkeyLoading &&
valkey?.keys.map(item => (
<TableRow key={item.key}>
<TableCell className="max-w-[320px] break-all font-mono text-xs">
{item.key}
</TableCell>
<TableCell>
<Badge variant="outline">{item.type}</Badge>
</TableCell>
<TableCell>{item.ttlSeconds < 0 ? 'persistent' : `${item.ttlSeconds}s`}</TableCell>
<TableCell>{item.size ?? 'n/a'}</TableCell>
<TableCell className="max-w-[360px] break-all font-mono text-xs">
{item.preview ?? 'No preview'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</>
)}
<Card>
<CardHeader>
<CardTitle>Infrastructure Dependencies</CardTitle>
@ -258,9 +632,19 @@ export default function OpsPage() {
</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">Stripe API</TableCell>
<TableCell>Payments</TableCell>
<TableCell>Global</TableCell>
<TableCell className="font-medium">Azure Blob Storage</TableCell>
<TableCell>Storage</TableCell>
<TableCell>Local Emulator</TableCell>
<TableCell>
<Badge variant="outline" className="bg-green-500/10 text-green-500">
Operational
</Badge>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">VM Disk + Runtime</TableCell>
<TableCell>Host</TableCell>
<TableCell>Azure VM</TableCell>
<TableCell>
<Badge variant="outline" className="bg-green-500/10 text-green-500">
Operational
@ -271,6 +655,42 @@ export default function OpsPage() {
</Table>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Internal Stack Coverage</CardTitle>
<CardDescription>What this admin ops surface covers today.</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-3">
<div className="rounded-lg border p-4">
<div className="mb-2 flex items-center gap-2 font-medium">
<Activity className="h-4 w-4" />
Health Review
</div>
<p className="text-sm text-muted-foreground">
Live status for dashboards, core services, observability, ingress, and shared infrastructure.
</p>
</div>
<div className="rounded-lg border p-4">
<div className="mb-2 flex items-center gap-2 font-medium">
<Database className="h-4 w-4" />
Valkey Visibility
</div>
<p className="text-sm text-muted-foreground">
Read-only key inspection with type, TTL, size, and preview for current internal data.
</p>
</div>
<div className="rounded-lg border p-4">
<div className="mb-2 flex items-center gap-2 font-medium">
<HardDrive className="h-4 w-4" />
VM Tooling
</div>
<p className="text-sm text-muted-foreground">
Inventory of Docker-managed services plus host tools used to run and operate the VM.
</p>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdmin } from '@/lib/auth-server';
import { HOST_TOOLS, collectInventoryServices } from '@/lib/ops-stack';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
try {
const admin = await requireAdmin(req);
if (!admin) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const services = await collectInventoryServices();
const counts = {
services: services.length,
healthy: services.filter(service => service.status === 'healthy').length,
degraded: services.filter(service => service.status === 'degraded').length,
down: services.filter(service => service.status === 'down').length,
hostTools: HOST_TOOLS.length,
};
return NextResponse.json({
timestamp: new Date().toISOString(),
counts,
services,
hostTools: HOST_TOOLS,
});
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Unauthorized' },
{ status: 401 }
);
}
}

View File

@ -1,265 +1,19 @@
import net from 'node:net';
import { NextResponse } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { requireAdmin } from '@/lib/auth-server';
import { collectOpsStatus } from '@/lib/ops-stack';
export const dynamic = 'force-dynamic';
type ServiceStatus = 'healthy' | 'degraded' | 'down' | 'maintenance';
type CheckKind = 'http-json' | 'http-status' | 'tcp';
interface ServiceCheck {
id: string;
name: string;
group: string;
target: string;
status: ServiceStatus;
latency: number;
version?: string;
message?: string;
lastChecked: string;
}
interface OpsStatus {
overall: 'healthy' | 'degraded' | 'critical';
timestamp: string;
services: ServiceCheck[];
}
interface HttpServiceDefinition {
id: string;
name: string;
group: string;
kind: 'http-json' | 'http-status';
env?: string;
default: string;
path: string;
}
interface TcpServiceDefinition {
id: string;
name: string;
group: string;
kind: 'tcp';
host: string;
port: number;
}
type ServiceDefinition = HttpServiceDefinition | TcpServiceDefinition;
const SERVICES: ServiceDefinition[] = [
{
id: 'admin-web',
name: 'Admin Dashboard',
group: 'Dashboards',
kind: 'http-status',
default: 'http://admin-web:3001',
path: '/api/health',
},
{
id: 'tracker-web',
name: 'Tracker Dashboard',
group: 'Dashboards',
kind: 'http-status',
default: 'http://tracker-web:3003',
path: '/api/health',
},
{
id: 'platform',
name: 'Platform Service',
group: 'Core Services',
env: 'PLATFORM_SERVICE_URL',
kind: 'http-json',
default: 'http://platform-service:4003',
path: '/health',
},
{
id: 'extraction',
name: 'Extraction Service',
group: 'Core Services',
env: 'EXTRACTION_SERVICE_URL',
kind: 'http-json',
default: 'http://extraction-service:4005',
path: '/health',
},
{
id: 'mcp',
name: 'MCP Server',
group: 'Core Services',
env: 'MCP_SERVER_URL',
kind: 'http-json',
default: 'http://mcp-server:4007',
path: '/health',
},
{
id: 'grafana',
name: 'Grafana',
group: 'Observability',
kind: 'http-json',
default: 'http://grafana:3000',
path: '/api/health',
},
{
id: 'loki',
name: 'Loki',
group: 'Observability',
kind: 'http-status',
default: 'http://loki:3100',
path: '/ready',
},
{
id: 'prometheus',
name: 'Prometheus',
group: 'Observability',
kind: 'http-status',
default: 'http://prometheus:9090',
path: '/-/healthy',
},
{
id: 'node-exporter',
name: 'Node Exporter',
group: 'Observability',
kind: 'http-status',
default: 'http://node-exporter:9100',
path: '/metrics',
},
{
id: 'cadvisor',
name: 'cAdvisor',
group: 'Observability',
kind: 'http-status',
default: 'http://cadvisor:8080',
path: '/healthz',
},
{
id: 'valkey',
name: 'Valkey',
group: 'Shared Infrastructure',
kind: 'tcp',
host: 'valkey',
port: 6379,
},
];
async function checkHttpService(service: HttpServiceDefinition): Promise<ServiceCheck> {
const baseUrl = (service.env && process.env[service.env]) || service.default;
const target = `${baseUrl}${service.path}`;
const start = Date.now();
export async function GET(req: NextRequest) {
try {
const res = await fetch(target, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
next: { revalidate: 0 },
signal: AbortSignal.timeout(3000),
});
const admin = await requireAdmin(req);
if (!admin) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const latency = Date.now() - start;
if (!res.ok) {
return {
id: service.id,
name: service.name,
group: service.group,
target,
status: 'down',
latency,
message: `HTTP ${res.status}`,
lastChecked: new Date().toISOString(),
};
}
if (service.kind === 'http-json') {
const payload = await res.json().catch(() => null);
const rawStatus = payload?.status;
const isOk =
rawStatus === 'ok' ||
rawStatus === 'healthy' ||
payload?.database === 'ok' ||
payload?.commit === 'ok';
return {
id: service.id,
name: service.name,
group: service.group,
target,
status: isOk ? 'healthy' : 'degraded',
latency,
version: payload?.version,
message: isOk ? undefined : JSON.stringify(payload),
lastChecked: new Date().toISOString(),
};
}
return {
id: service.id,
name: service.name,
group: service.group,
target,
status: 'healthy',
latency,
lastChecked: new Date().toISOString(),
};
} catch (err) {
return {
id: service.id,
name: service.name,
group: service.group,
target,
status: 'down',
latency: Date.now() - start,
message: err instanceof Error ? err.message : String(err),
lastChecked: new Date().toISOString(),
};
return NextResponse.json(await collectOpsStatus());
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Unauthorized' },
{ status: 401 }
);
}
}
async function checkTcpService(service: TcpServiceDefinition): Promise<ServiceCheck> {
const start = Date.now();
const target = `${service.host}:${service.port}`;
return new Promise(resolve => {
const socket = net.createConnection({ host: service.host, port: service.port });
let settled = false;
const finish = (status: ServiceStatus, message?: string) => {
if (settled) return;
settled = true;
socket.destroy();
resolve({
id: service.id,
name: service.name,
group: service.group,
target,
status,
latency: Date.now() - start,
message,
lastChecked: new Date().toISOString(),
});
};
socket.setTimeout(3000);
socket.once('connect', () => finish('healthy'));
socket.once('timeout', () => finish('down', 'Connection timed out'));
socket.once('error', err => finish('down', err.message));
});
}
export async function GET() {
const checks = await Promise.all(
SERVICES.map(service =>
service.kind === 'tcp' ? checkTcpService(service) : checkHttpService(service)
)
);
const downCount = checks.filter(c => c.status === 'down').length;
const degradedCount = checks.filter(c => c.status === 'degraded').length;
let overall: OpsStatus['overall'] = 'healthy';
if (downCount > 0) overall = 'critical';
else if (degradedCount > 0) overall = 'degraded';
return NextResponse.json({
overall,
timestamp: new Date().toISOString(),
services: checks,
} satisfies OpsStatus);
}

View File

@ -0,0 +1,147 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from 'redis';
import { requireAdmin } from '@/lib/auth-server';
export const dynamic = 'force-dynamic';
interface ValkeyPreview {
key: string;
type: string;
ttlSeconds: number;
size?: number;
preview?: string;
}
function sanitizePattern(input: string | null): string {
const value = (input || '*').trim();
if (!value) return '*';
if (value.length > 120 || /[\r\n]/.test(value)) {
throw new Error('Invalid key pattern');
}
return value;
}
function truncate(value: string, max = 160): string {
return value.length > max ? `${value.slice(0, max)}...` : value;
}
function parseInfoValue(info: string, key: string): string | undefined {
const line = info
.split('\n')
.map(item => item.trim())
.find(item => item.startsWith(`${key}:`));
return line?.split(':').slice(1).join(':');
}
async function getPreview(client: ReturnType<typeof createClient>, key: string): Promise<ValkeyPreview> {
const [type, ttlSeconds] = await Promise.all([client.type(key), client.ttl(key)]);
if (type === 'string') {
const value = await client.get(key);
return { key, type, ttlSeconds, preview: truncate(value ?? '') };
}
if (type === 'hash') {
const [size, entries] = await Promise.all([client.hLen(key), client.hGetAll(key)]);
return {
key,
type,
ttlSeconds,
size,
preview: truncate(JSON.stringify(Object.fromEntries(Object.entries(entries).slice(0, 5)))),
};
}
if (type === 'list') {
const [size, entries] = await Promise.all([client.lLen(key), client.lRange(key, 0, 4)]);
return {
key,
type,
ttlSeconds,
size,
preview: truncate(JSON.stringify(entries)),
};
}
if (type === 'set') {
const [size, entries] = await Promise.all([client.sCard(key), client.sMembers(key)]);
return {
key,
type,
ttlSeconds,
size,
preview: truncate(JSON.stringify(entries.slice(0, 5))),
};
}
if (type === 'zset') {
const [size, entries] = await Promise.all([client.zCard(key), client.zRangeWithScores(key, 0, 4)]);
return {
key,
type,
ttlSeconds,
size,
preview: truncate(JSON.stringify(entries)),
};
}
return { key, type, ttlSeconds };
}
export async function GET(req: NextRequest) {
try {
const admin = await requireAdmin(req);
if (!admin) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const url = new URL(req.url);
const pattern = sanitizePattern(url.searchParams.get('pattern'));
const limit = Math.min(Math.max(Number(url.searchParams.get('limit') || '25'), 1), 100);
const client = createClient({
url: process.env.VALKEY_URL || 'redis://valkey:6379',
});
try {
await client.connect();
const [ping, dbsize, serverInfo, memoryInfo] = await Promise.all([
client.ping(),
client.dbSize(),
client.info('server'),
client.info('memory'),
]);
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 previews = await Promise.all(keys.map(key => getPreview(client, key)));
return NextResponse.json({
timestamp: new Date().toISOString(),
pattern,
limit,
summary: {
ping,
dbsize,
matchedKeys: previews.length,
version: parseInfoValue(serverInfo, 'redis_version') || 'unknown',
usedMemoryHuman: parseInfoValue(memoryInfo, 'used_memory_human') || 'unknown',
usedMemoryPeakHuman: parseInfoValue(memoryInfo, 'used_memory_peak_human') || 'unknown',
},
keys: previews,
});
} finally {
if (client.isOpen) {
await client.quit();
}
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unable to inspect Valkey';
return NextResponse.json({ error: message }, { status: message === 'Unauthorized' ? 401 : 500 });
}
}

View File

@ -0,0 +1,492 @@
import net from 'node:net';
export type ServiceStatus = 'healthy' | 'degraded' | 'down' | 'maintenance';
export type CheckKind = 'http-json' | 'http-status' | 'tcp';
export type InventorySource = 'docker' | 'vm';
export interface ServiceCheck {
id: string;
name: string;
group: string;
target: string;
status: ServiceStatus;
latency: number;
version?: string;
message?: string;
lastChecked: string;
}
export interface OpsStatus {
overall: 'healthy' | 'degraded' | 'critical';
timestamp: string;
services: ServiceCheck[];
}
interface BaseDefinition {
id: string;
name: string;
group: string;
description: string;
management: InventorySource;
exposure: 'internal' | 'public';
port?: number;
}
interface HttpServiceDefinition extends BaseDefinition {
kind: 'http-json' | 'http-status';
env?: string;
default: string;
path: string;
}
interface TcpServiceDefinition extends BaseDefinition {
kind: 'tcp';
host: string;
port: number;
}
export type ServiceDefinition = HttpServiceDefinition | TcpServiceDefinition;
export interface InventoryService extends ServiceCheck {
description: string;
management: InventorySource;
exposure: 'internal' | 'public';
port?: number;
}
export interface HostTool {
id: string;
name: string;
group: string;
source: InventorySource;
management: string;
status: 'managed' | 'manual';
description: string;
}
export const STACK_SERVICES: ServiceDefinition[] = [
{
id: 'admin-web',
name: 'Admin Dashboard',
group: 'Dashboards',
description: 'Internal admin portal for platform review and ops workflows.',
management: 'docker',
exposure: 'internal',
port: 3001,
kind: 'http-status',
default: 'http://admin-web:3001',
path: '/api/health',
},
{
id: 'tracker-web',
name: 'Tracker Dashboard',
group: 'Dashboards',
description: 'Internal tracker UI for issue and delivery review.',
management: 'docker',
exposure: 'internal',
port: 3003,
kind: 'http-status',
default: 'http://tracker-web:3003',
path: '/api/health',
},
{
id: 'platform',
name: 'Platform Service',
group: 'Core Services',
description: 'Core API and auth platform service.',
management: 'docker',
exposure: 'internal',
port: 4003,
env: 'PLATFORM_SERVICE_URL',
kind: 'http-json',
default: 'http://platform-service:4003',
path: '/health',
},
{
id: 'extraction',
name: 'Extraction Service',
group: 'Core Services',
description: 'Structured extraction service with product-aware throttling.',
management: 'docker',
exposure: 'internal',
port: 4005,
env: 'EXTRACTION_SERVICE_URL',
kind: 'http-json',
default: 'http://extraction-service:4005',
path: '/health',
},
{
id: 'mcp',
name: 'MCP Server',
group: 'Core Services',
description: 'Internal MCP integration surface.',
management: 'docker',
exposure: 'internal',
port: 4007,
env: 'MCP_SERVER_URL',
kind: 'http-json',
default: 'http://mcp-server:4007',
path: '/health',
},
{
id: 'grafana',
name: 'Grafana',
group: 'Observability',
description: 'Metrics and logs visualization.',
management: 'docker',
exposure: 'internal',
port: 3000,
kind: 'http-json',
default: 'http://grafana:3000',
path: '/api/health',
},
{
id: 'loki',
name: 'Loki',
group: 'Observability',
description: 'Centralized log aggregation.',
management: 'docker',
exposure: 'internal',
port: 3100,
kind: 'http-status',
default: 'http://loki:3100',
path: '/ready',
},
{
id: 'prometheus',
name: 'Prometheus',
group: 'Observability',
description: 'Internal metrics scraping and query engine.',
management: 'docker',
exposure: 'internal',
port: 9090,
kind: 'http-status',
default: 'http://prometheus:9090',
path: '/-/healthy',
},
{
id: 'node-exporter',
name: 'Node Exporter',
group: 'Observability',
description: 'Host-level VM metrics exporter.',
management: 'docker',
exposure: 'internal',
port: 9100,
kind: 'http-status',
default: 'http://node-exporter:9100',
path: '/metrics',
},
{
id: 'cadvisor',
name: 'cAdvisor',
group: 'Observability',
description: 'Container-level metrics exporter.',
management: 'docker',
exposure: 'internal',
port: 8080,
kind: 'http-status',
default: 'http://cadvisor:8080',
path: '/healthz',
},
{
id: 'valkey',
name: 'Valkey',
group: 'Shared Infrastructure',
description: 'Shared cache and rate-limit backing store.',
management: 'docker',
exposure: 'internal',
kind: 'tcp',
host: 'valkey',
port: 6379,
},
{
id: 'gitea-registry',
name: 'Gitea Registry',
group: 'Shared Infrastructure',
description: 'Private npm package registry and source control service.',
management: 'docker',
exposure: 'internal',
port: 3300,
kind: 'http-json',
default: 'http://gitea-npm-registry:3000',
path: '/api/v1/version',
},
{
id: 'mailpit',
name: 'Mailpit',
group: 'Shared Infrastructure',
description: 'SMTP sink and email inspection UI.',
management: 'docker',
exposure: 'internal',
port: 8025,
kind: 'http-status',
default: 'http://mailpit:8025',
path: '/',
},
{
id: 'azurite',
name: 'Azurite',
group: 'Shared Infrastructure',
description: 'Local Azure Blob Storage emulator.',
management: 'docker',
exposure: 'internal',
kind: 'tcp',
host: 'azurite',
port: 10000,
},
{
id: 'cosmos-emulator',
name: 'Cosmos Emulator',
group: 'Shared Infrastructure',
description: 'Local Azure Cosmos DB emulator.',
management: 'docker',
exposure: 'internal',
port: 8080,
kind: 'http-status',
default: 'http://cosmos-emulator:8080',
path: '/ready',
},
{
id: 'gateway',
name: 'Traefik Gateway',
group: 'Ingress',
description: 'Legacy internal gateway and routing layer.',
management: 'docker',
exposure: 'internal',
port: 8080,
kind: 'http-status',
default: 'http://gateway:8080',
path: '/',
},
{
id: 'caddy',
name: 'Caddy',
group: 'Ingress',
description: 'HTTPS ingress and reverse proxy for internal and backend domains.',
management: 'docker',
exposure: 'public',
kind: 'tcp',
host: 'caddy',
port: 80,
},
];
export const HOST_TOOLS: HostTool[] = [
{
id: 'docker-ce',
name: 'Docker CE',
group: 'Host Tooling',
source: 'vm',
management: 'VM bootstrap',
status: 'managed',
description: 'Container runtime for the internal stack.',
},
{
id: 'docker-compose',
name: 'Docker Compose',
group: 'Host Tooling',
source: 'vm',
management: 'VM bootstrap',
status: 'managed',
description: 'Multi-service orchestration for the VM stack.',
},
{
id: 'azure-cli',
name: 'Azure CLI',
group: 'Host Tooling',
source: 'vm',
management: 'Manual install',
status: 'manual',
description: 'Azure subscription and NSG management from the VM.',
},
{
id: 'nodejs',
name: 'Node.js 22',
group: 'Host Tooling',
source: 'vm',
management: 'VM bootstrap',
status: 'managed',
description: 'Build/runtime toolchain for workspace services.',
},
{
id: 'pnpm',
name: 'pnpm',
group: 'Host Tooling',
source: 'vm',
management: 'VM bootstrap',
status: 'managed',
description: 'Workspace package manager.',
},
{
id: 'git',
name: 'git',
group: 'Host Tooling',
source: 'vm',
management: 'VM bootstrap',
status: 'managed',
description: 'Repo sync and deployment workflow tooling.',
},
{
id: 'jq',
name: 'jq',
group: 'Host Tooling',
source: 'vm',
management: 'VM bootstrap',
status: 'managed',
description: 'CLI JSON inspection used in ops and setup scripts.',
},
{
id: 'caddy-host-config',
name: 'Caddy Config',
group: 'Host Tooling',
source: 'vm',
management: 'VM file mount',
status: 'managed',
description: 'Host-mounted Caddy configuration at /opt/bytelyst/Caddyfile.',
},
];
async function checkHttpService(service: HttpServiceDefinition): Promise<ServiceCheck> {
const baseUrl = (service.env && process.env[service.env]) || service.default;
const target = `${baseUrl}${service.path}`;
const start = Date.now();
try {
const res = await fetch(target, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
next: { revalidate: 0 },
signal: AbortSignal.timeout(3000),
});
const latency = Date.now() - start;
if (!res.ok) {
return {
id: service.id,
name: service.name,
group: service.group,
target,
status: 'down',
latency,
message: `HTTP ${res.status}`,
lastChecked: new Date().toISOString(),
};
}
if (service.kind === 'http-json') {
const payload = await res.json().catch(() => null);
const rawStatus = payload?.status;
const isOk =
rawStatus === 'ok' ||
rawStatus === 'healthy' ||
payload?.database === 'ok' ||
payload?.commit === 'ok' ||
payload?.version;
return {
id: service.id,
name: service.name,
group: service.group,
target,
status: isOk ? 'healthy' : 'degraded',
latency,
version: payload?.version,
message: isOk ? undefined : JSON.stringify(payload),
lastChecked: new Date().toISOString(),
};
}
return {
id: service.id,
name: service.name,
group: service.group,
target,
status: 'healthy',
latency,
lastChecked: new Date().toISOString(),
};
} catch (err) {
return {
id: service.id,
name: service.name,
group: service.group,
target,
status: 'down',
latency: Date.now() - start,
message: err instanceof Error ? err.message : String(err),
lastChecked: new Date().toISOString(),
};
}
}
async function checkTcpService(service: TcpServiceDefinition): Promise<ServiceCheck> {
const start = Date.now();
const target = `${service.host}:${service.port}`;
return new Promise(resolve => {
const socket = net.createConnection({ host: service.host, port: service.port });
let settled = false;
const finish = (status: ServiceStatus, message?: string) => {
if (settled) return;
settled = true;
socket.destroy();
resolve({
id: service.id,
name: service.name,
group: service.group,
target,
status,
latency: Date.now() - start,
message,
lastChecked: new Date().toISOString(),
});
};
socket.setTimeout(3000);
socket.once('connect', () => finish('healthy'));
socket.once('timeout', () => finish('down', 'Connection timed out'));
socket.once('error', err => finish('down', err.message));
});
}
export async function collectOpsChecks(): Promise<ServiceCheck[]> {
return Promise.all(
STACK_SERVICES.map(service =>
service.kind === 'tcp' ? checkTcpService(service) : checkHttpService(service)
)
);
}
export async function collectOpsStatus(): Promise<OpsStatus> {
const services = await collectOpsChecks();
const downCount = services.filter(c => c.status === 'down').length;
const degradedCount = services.filter(c => c.status === 'degraded').length;
let overall: OpsStatus['overall'] = 'healthy';
if (downCount > 0) overall = 'critical';
else if (degradedCount > 0) overall = 'degraded';
return {
overall,
timestamp: new Date().toISOString(),
services,
};
}
export async function collectInventoryServices(): Promise<InventoryService[]> {
const checks = await collectOpsChecks();
const byId = new Map(checks.map(check => [check.id, check]));
return STACK_SERVICES.map(service => {
const check = byId.get(service.id);
return {
...(check as ServiceCheck),
description: service.description,
management: service.management,
exposure: service.exposure,
port: service.port,
};
});
}

3
pnpm-lock.yaml generated
View File

@ -156,6 +156,9 @@ importers:
recharts:
specifier: ^3.7.0
version: 3.7.0(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react-is@18.3.1)(react@19.2.3)(redux@5.0.1)
redis:
specifier: ^4.7.0
version: 4.7.1
remark-gfm:
specifier: ^4.0.1
version: 4.0.1