feat: implement Swapper metrics collection and UI

This commit is contained in:
Apple
2025-11-30 15:12:49 -08:00
parent 5b5160ad8b
commit fd814b2059
11 changed files with 1224 additions and 4543 deletions

View File

@@ -18,6 +18,7 @@ import {
NodeMetricsCard
} from '@/components/node-dashboard';
import { NodeGuardianCard } from '@/components/nodes/NodeGuardianCard';
import { NodeSwapperCard } from '@/components/node-dashboard/NodeSwapperCard';
import { AgentChatWidget } from '@/components/chat/AgentChatWidget';
function getNodeLabel(nodeId: string): string {
@@ -151,6 +152,9 @@ export default function NodeCabinetPage() {
steward={steward ? { id: steward.id, name: steward.name, kind: steward.kind, slug: steward.slug } : nodeProfile?.steward_agent}
/>
{/* Swapper Service */}
<NodeSwapperCard nodeId={nodeId} />
{/* MicroDAO Presence */}
{nodeProfile?.microdaos && nodeProfile.microdaos.length > 0 && (
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6">
@@ -310,6 +314,11 @@ export default function NodeCabinetPage() {
/>
</div>
{/* Swapper Service (if production or dev with services) */}
<div className="mb-6">
<NodeSwapperCard nodeId={nodeId} />
</div>
{/* Node Metrics (GPU/CPU/RAM/Disk) */}
<div className="mb-6">
<NodeMetricsCard nodeId={nodeId} />

View File

@@ -0,0 +1,110 @@
'use client';
import { useNodeSwapper, SwapperModel } from '@/hooks/useNodeSwapper';
import { useState } from 'react';
interface NodeSwapperCardProps {
nodeId: string;
}
export function NodeSwapperCard({ nodeId }: NodeSwapperCardProps) {
const { swapper, isLoading, error, mutate } = useNodeSwapper(nodeId);
const [expanded, setExpanded] = useState(false);
if (isLoading) {
return (
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6 animate-pulse">
<div className="h-6 bg-white/10 rounded w-1/3 mb-4" />
<div className="h-4 bg-white/10 rounded w-1/2" />
</div>
);
}
if (error) {
return (
<div className="bg-red-500/10 backdrop-blur-md rounded-2xl border border-red-500/20 p-6 text-red-400">
<h3 className="font-semibold flex items-center gap-2">
<span>🧠</span> Swapper Service
</h3>
<p className="text-sm mt-2">Failed to load status</p>
</div>
);
}
if (!swapper) return null;
const statusColor = swapper.healthy ? 'text-green-400' : 'text-red-400';
const statusIcon = swapper.healthy ? '🟢' : '🔴';
const statusText = swapper.healthy ? 'Healthy' : 'Down';
return (
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6 transition-all hover:border-cyan-500/30">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<span>🧠</span> Swapper Service
</h3>
<button
onClick={() => mutate()}
className="text-white/30 hover:text-white/70 transition-colors"
title="Refresh"
>
</button>
</div>
<div className="space-y-3">
{/* Status Row */}
<div className="flex items-center justify-between text-sm">
<span className="text-white/50">Status:</span>
<span className={`font-medium flex items-center gap-1.5 ${statusColor}`}>
{statusIcon} {statusText}
</span>
</div>
{/* Summary Row */}
<div className="flex items-center justify-between text-sm">
<span className="text-white/50">Models:</span>
<span className="text-white font-medium">
{swapper.models_loaded} / {swapper.models_total} loaded
</span>
</div>
{/* Details Toggle */}
<button
onClick={() => setExpanded(!expanded)}
className="w-full mt-2 py-2 text-xs font-medium text-white/40 hover:text-white/70 bg-white/5 hover:bg-white/10 rounded-lg transition-colors flex items-center justify-center gap-1"
>
{expanded ? 'Hide Models' : 'View Models'}
<span>{expanded ? '▲' : '▼'}</span>
</button>
{/* Models List */}
{expanded && (
<div className="mt-3 space-y-2 border-t border-white/10 pt-3">
{swapper.models.length === 0 ? (
<p className="text-white/30 text-xs text-center py-2">No models found</p>
) : (
swapper.models.map((model, idx) => (
<div key={`${model.name}-${idx}`} className="flex items-center justify-between text-xs bg-black/20 p-2 rounded border border-white/5">
<div className="flex flex-col gap-0.5">
<span className="text-white font-medium">{model.name}</span>
<span className="text-white/40 uppercase text-[10px]">{model.type || 'unknown'}</span>
</div>
<div className="flex flex-col items-end gap-0.5">
<span className={model.loaded ? 'text-green-400' : 'text-white/30'}>
{model.loaded ? '● LOADED' : '○ UNLOADED'}
</span>
{model.vram_gb && (
<span className="text-white/30">{model.vram_gb.toFixed(1)} GB</span>
)}
</div>
</div>
))
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import useSWR from "swr";
const fetcher = (url: string) => fetch(url, { credentials: "include" }).then(r => {
if (!r.ok) throw new Error(`Failed to load ${url}`);
return r.json();
});
export interface SwapperModel {
name: string;
loaded: boolean;
type?: string;
vram_gb?: number;
}
export interface NodeSwapperDetail {
node_id: string;
healthy: boolean;
models_loaded: number;
models_total: number;
models: SwapperModel[];
}
export function useNodeSwapper(nodeId?: string) {
const { data, error, isLoading, mutate } = useSWR<NodeSwapperDetail>(
nodeId ? `/api/v1/internal/node/${nodeId}/swapper` : null,
fetcher
);
return {
swapper: data,
isLoading,
error,
mutate,
};
}