chore: snapshot main sync
This commit is contained in:
@@ -1,18 +1,30 @@
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useState } from 'react';
|
||||
import { ControlFleetHost } from '@/hooks/useControlStream';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { ControlFleetHost, ControlPerfSample } from '@/hooks/useControlStream';
|
||||
import { useReducedMotion } from '@/hooks/useReducedMotion';
|
||||
import { VramGauge } from './VramGauge';
|
||||
import { TtlRing } from './TtlRing';
|
||||
import { PerfChart } from './PerfChart';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { GpuData } from './FleetTab';
|
||||
import { Play, Eraser } from 'lucide-react';
|
||||
import { Play, Eraser, Check, Loader2, AlertTriangle, Circle, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
|
||||
interface HostCardProps {
|
||||
host: ControlFleetHost;
|
||||
gpuData: GpuData | null;
|
||||
perfSamples?: ControlPerfSample[];
|
||||
}
|
||||
|
||||
// C1: redundant (non-color) signifier per model state for color-blind users.
|
||||
const STATE_ICON: Record<string, typeof Check> = {
|
||||
ready: Check,
|
||||
starting: Loader2,
|
||||
stopping: Loader2,
|
||||
error: AlertTriangle,
|
||||
stopped: Circle,
|
||||
down: Circle,
|
||||
};
|
||||
|
||||
const STATE_COLORS: Record<string, { bg: string; glowVar: string; animate: boolean }> = {
|
||||
starting: { bg: 'bg-amber-500', glowVar: '--glow-amber', animate: true },
|
||||
ready: { bg: 'bg-green-500', glowVar: '--glow-green', animate: false },
|
||||
@@ -40,7 +52,6 @@ function relTime(iso: string | null): string {
|
||||
function livenessLabel(state: string): string {
|
||||
switch (state) {
|
||||
case 'connected': return 'connected';
|
||||
case 'reconnecting': return 'reconnecting';
|
||||
case 'down': return 'down';
|
||||
default: return state;
|
||||
}
|
||||
@@ -50,11 +61,33 @@ function getGlowColor(glowVar: string): string {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(glowVar).trim();
|
||||
}
|
||||
|
||||
export function HostCard({ host, gpuData }: HostCardProps) {
|
||||
export function HostCard({ host, gpuData, perfSamples = [] }: HostCardProps) {
|
||||
const reducedMotion = useReducedMotion();
|
||||
const livenessKey = host.liveness === 'connected' ? 'ready' : host.liveness === 'reconnecting' ? 'starting' : host.liveness;
|
||||
const [showPerf, setShowPerf] = useState(false);
|
||||
|
||||
// B2: build perf history series for this host from buffered samples.
|
||||
const perf = useMemo(() => {
|
||||
const mine = perfSamples.filter((s) => s.providerId === host.providerId).slice(-120);
|
||||
const timestamps = mine.map((s) => s.ts);
|
||||
const num = (g: unknown, k: string): number => {
|
||||
const v = (g as Record<string, unknown> | null)?.[k];
|
||||
return typeof v === 'number' ? v : 0;
|
||||
};
|
||||
return {
|
||||
timestamps,
|
||||
hasData: mine.length > 1,
|
||||
series: [
|
||||
{ name: 'VRAM MB', data: mine.map((s) => num(s.gpu, 'vram_used')), color: '#60a5fa' },
|
||||
{ name: 'Temp C', data: mine.map((s) => num(s.gpu, 'temperature')), color: '#f87171' },
|
||||
{ name: 'Power W', data: mine.map((s) => num(s.gpu, 'power')), color: '#fbbf24' },
|
||||
],
|
||||
};
|
||||
}, [perfSamples, host.providerId]);
|
||||
const livenessKey = host.liveness === 'connected' ? 'ready' : host.liveness;
|
||||
const stateConfig = STATE_COLORS[livenessKey] ?? FALLBACK_STATE;
|
||||
const glowColor = getGlowColor(stateConfig.glowVar);
|
||||
// AUD1: getComputedStyle is a forced style read; memoize so it doesn't fire on
|
||||
// every WS-delta re-render (only the theme token changes it, which is rare).
|
||||
const glowColor = useMemo(() => getGlowColor(stateConfig.glowVar), [stateConfig.glowVar]);
|
||||
|
||||
const vramUsed = gpuData?.vram_used ?? 0;
|
||||
const vramTotal = gpuData?.vram_total ?? 0;
|
||||
@@ -110,9 +143,16 @@ export function HostCard({ host, gpuData }: HostCardProps) {
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="text-[10px] text-muted-foreground ml-auto font-mono">
|
||||
seq {host.seq}
|
||||
</span>
|
||||
{perf.hasData && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPerf((v) => !v)}
|
||||
className="text-[10px] text-muted-foreground ml-auto inline-flex items-center gap-0.5 hover:text-foreground"
|
||||
>
|
||||
{showPerf ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
|
||||
perf
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
@@ -146,7 +186,7 @@ export function HostCard({ host, gpuData }: HostCardProps) {
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{host.models.map((m) => (
|
||||
<ModelChip key={`${m.model}-${m.state}`} model={m} />
|
||||
<ModelChip key={`${m.model}-${m.state}`} model={m} providerId={host.providerId} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
@@ -171,6 +211,13 @@ export function HostCard({ host, gpuData }: HostCardProps) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* B2: perf history (collapsible) */}
|
||||
{showPerf && perf.hasData && (
|
||||
<div className="mt-4 border-t border-border/30 pt-3">
|
||||
<PerfChart series={perf.series} timestamps={perf.timestamps} height={180} />
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -196,11 +243,13 @@ interface ModelChipProps {
|
||||
ttlDeadline: string | null;
|
||||
inflight: number;
|
||||
};
|
||||
providerId: string;
|
||||
}
|
||||
|
||||
function ModelChip({ model }: ModelChipProps) {
|
||||
function ModelChip({ model, providerId }: ModelChipProps) {
|
||||
const reducedMotion = useReducedMotion();
|
||||
const stateConfig = STATE_COLORS[model.state] ?? FALLBACK_STATE;
|
||||
const StateIcon = STATE_ICON[model.state] ?? Circle;
|
||||
const spin = model.state === 'starting' || model.state === 'stopping';
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [confirmUnload, setConfirmUnload] = useState(false);
|
||||
|
||||
@@ -211,7 +260,7 @@ function ModelChip({ model }: ModelChipProps) {
|
||||
const res = await fetch('/api/control/action/submit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type: 'warm', providerId: model.model.split(':')[0], model: model.model }),
|
||||
body: JSON.stringify({ type: 'warm', providerId, model: model.model }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
@@ -231,7 +280,7 @@ function ModelChip({ model }: ModelChipProps) {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'unload',
|
||||
providerId: model.model.split(':')[0],
|
||||
providerId,
|
||||
model: model.model,
|
||||
confirmed,
|
||||
}),
|
||||
@@ -266,18 +315,23 @@ function ModelChip({ model }: ModelChipProps) {
|
||||
exit={reducedMotion ? undefined : { scale: 0.8, opacity: 0 }}
|
||||
transition={reducedMotion ? undefined : { type: 'spring', stiffness: 400, damping: 20 }}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs',
|
||||
'relative inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs',
|
||||
'border border-border/40 bg-muted/30',
|
||||
'font-medium',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
<StateIcon
|
||||
aria-label={model.state}
|
||||
className={cn(
|
||||
'w-1.5 h-1.5 rounded-full shrink-0',
|
||||
stateConfig.bg,
|
||||
'size-3 shrink-0',
|
||||
spin && 'animate-spin',
|
||||
model.state === 'ready' && 'text-green-400',
|
||||
model.state === 'error' && 'text-red-400',
|
||||
(model.state === 'starting' || model.state === 'stopping') && 'text-amber-400',
|
||||
(model.state === 'stopped' || model.state === 'down') && 'text-gray-400',
|
||||
)}
|
||||
/>
|
||||
<span className="truncate max-w-[160px]">{model.model}</span>
|
||||
<span className="truncate max-w-[160px]" title={`${model.model} (${model.state})`}>{model.model}</span>
|
||||
{model.inflight > 0 && (
|
||||
<span className="text-[10px] text-muted-foreground ml-0.5">
|
||||
({model.inflight})
|
||||
|
||||
Reference in New Issue
Block a user