feat: implement Swapper metrics collection and UI
This commit is contained in:
@@ -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} />
|
||||
|
||||
110
apps/web/src/components/node-dashboard/NodeSwapperCard.tsx
Normal file
110
apps/web/src/components/node-dashboard/NodeSwapperCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
36
apps/web/src/hooks/useNodeSwapper.ts
Normal file
36
apps/web/src/hooks/useNodeSwapper.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user