chore: snapshot working tree - pty_exited notifications + in-flight inference WIP
feat(booterm): structured pty_exited WS notifications. Plan-validated, impl-validated, code-reviewed green (contracts build clean, contracts test 29/29, booterm + web typecheck clean). wip: in-progress inference/provider refactor (agents.ts, provider.ts, new llama-providers.ts, removed llama-args-validator), plus arena, dispatcher, compaction, schema changes. openspec: pty-exit-notifications complete; x-agent-flags planned (not yet implemented).
This commit is contained in:
336
apps/web/src/components/control/HostCard.tsx
Normal file
336
apps/web/src/components/control/HostCard.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useState } from 'react';
|
||||
import { ControlFleetHost } from '@/hooks/useControlStream';
|
||||
import { useReducedMotion } from '@/hooks/useReducedMotion';
|
||||
import { VramGauge } from './VramGauge';
|
||||
import { TtlRing } from './TtlRing';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { GpuData } from './FleetTab';
|
||||
import { Play, Eraser } from 'lucide-react';
|
||||
|
||||
interface HostCardProps {
|
||||
host: ControlFleetHost;
|
||||
gpuData: GpuData | null;
|
||||
}
|
||||
|
||||
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 },
|
||||
error: { bg: 'bg-red-500', glowVar: '--glow-red', animate: false },
|
||||
down: { bg: 'bg-gray-500', glowVar: '--glow-gray', animate: false },
|
||||
stopped: { bg: 'bg-gray-400', glowVar: '--glow-gray', animate: false },
|
||||
stopping: { bg: 'bg-amber-400', glowVar: '--glow-amber', animate: true },
|
||||
};
|
||||
|
||||
const FALLBACK_STATE = { bg: 'bg-gray-500', glowVar: '--glow-gray', animate: false };
|
||||
|
||||
function relTime(iso: string | null): string {
|
||||
if (!iso) return '';
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
function livenessLabel(state: string): string {
|
||||
switch (state) {
|
||||
case 'connected': return 'connected';
|
||||
case 'reconnecting': return 'reconnecting';
|
||||
case 'down': return 'down';
|
||||
default: return state;
|
||||
}
|
||||
}
|
||||
|
||||
function getGlowColor(glowVar: string): string {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(glowVar).trim();
|
||||
}
|
||||
|
||||
export function HostCard({ host, gpuData }: HostCardProps) {
|
||||
const reducedMotion = useReducedMotion();
|
||||
const livenessKey = host.liveness === 'connected' ? 'ready' : host.liveness === 'reconnecting' ? 'starting' : host.liveness;
|
||||
const stateConfig = STATE_COLORS[livenessKey] ?? FALLBACK_STATE;
|
||||
const glowColor = getGlowColor(stateConfig.glowVar);
|
||||
|
||||
const vramUsed = gpuData?.vram_used ?? 0;
|
||||
const vramTotal = gpuData?.vram_total ?? 0;
|
||||
const gpuTemp = gpuData?.temperature ?? null;
|
||||
const gpuPower = gpuData?.power ?? null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={reducedMotion ? undefined : { opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={reducedMotion ? undefined : { opacity: 0, scale: 0.97 }}
|
||||
transition={reducedMotion ? undefined : { type: 'spring', stiffness: 300, damping: 25 }}
|
||||
className={cn(
|
||||
'rounded-xl border border-border/60 bg-card p-4',
|
||||
'shadow-sm',
|
||||
)}
|
||||
>
|
||||
{/* Header: provider ID + liveness chip + last seen */}
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<h2 className="text-sm font-semibold tracking-tight">{host.providerId}</h2>
|
||||
|
||||
<motion.div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[11px] font-medium',
|
||||
'border border-border/40',
|
||||
)}
|
||||
animate={
|
||||
reducedMotion
|
||||
? undefined
|
||||
: stateConfig.animate
|
||||
? { boxShadow: ['0 0 0px transparent', `0 0 8px ${glowColor}33`, '0 0 0px transparent'] }
|
||||
: { boxShadow: [`0 0 6px ${glowColor}33`] }
|
||||
}
|
||||
transition={
|
||||
reducedMotion
|
||||
? undefined
|
||||
: { duration: 1.5, repeat: stateConfig.animate ? Infinity : 0 }
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'w-1.5 h-1.5 rounded-full',
|
||||
stateConfig.bg,
|
||||
)}
|
||||
/>
|
||||
<span className="capitalize">{livenessLabel(host.liveness)}</span>
|
||||
</motion.div>
|
||||
|
||||
{host.liveness === 'down' && host.lastSeenAt && (
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
last seen {relTime(host.lastSeenAt)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="text-[10px] text-muted-foreground ml-auto font-mono">
|
||||
seq {host.seq}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
{/* Left: VRAM gauge + GPU readouts */}
|
||||
<div className="flex items-start gap-4 shrink-0">
|
||||
{vramTotal > 0 ? (
|
||||
<VramGauge used={vramUsed} total={vramTotal} size={110} />
|
||||
) : (
|
||||
<div className="w-[110px] h-[110px] flex items-center justify-center text-[11px] text-muted-foreground">
|
||||
no GPU data
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* GPU readouts */}
|
||||
<div className="space-y-2 pt-2">
|
||||
{gpuTemp != null && (
|
||||
<GpuReadout label="Temp" value={`${gpuTemp.toFixed(0)}\u00B0C`} />
|
||||
)}
|
||||
{gpuPower != null && (
|
||||
<GpuReadout label="Power" value={`${gpuPower.toFixed(0)}W`} />
|
||||
)}
|
||||
<GpuReadout label="VRAM" value={`${vramUsed.toFixed(0)} / ${vramTotal.toFixed(0)} MB`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: model chips + TTL rings */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-medium">
|
||||
Models
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{host.models.map((m) => (
|
||||
<ModelChip key={`${m.model}-${m.state}`} model={m} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* TTL rings */}
|
||||
{host.models.some((m) => m.ttlDeadline) && (
|
||||
<div className="mt-3">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-medium">
|
||||
TTL
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{host.models.filter((m) => m.ttlDeadline).map((m) => (
|
||||
<div key={`ttl-${m.model}`} className="flex flex-col items-center gap-1">
|
||||
<TtlRing deadline={m.ttlDeadline} size={64} />
|
||||
<span className="text-[10px] text-muted-foreground truncate max-w-[80px]">
|
||||
{m.model}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function GpuReadout({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-sm font-bold font-[Orbitron] tabular-nums text-foreground">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ModelChipProps {
|
||||
model: {
|
||||
model: string;
|
||||
state: string;
|
||||
ts: string;
|
||||
ttlDeadline: string | null;
|
||||
inflight: number;
|
||||
};
|
||||
}
|
||||
|
||||
function ModelChip({ model }: ModelChipProps) {
|
||||
const reducedMotion = useReducedMotion();
|
||||
const stateConfig = STATE_COLORS[model.state] ?? FALLBACK_STATE;
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [confirmUnload, setConfirmUnload] = useState(false);
|
||||
|
||||
// P2.2: Optimistic UI — API calls only, no local state mutation.
|
||||
// The control_fleet delta from WS updates the UI.
|
||||
const handleWarm = async () => {
|
||||
try {
|
||||
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 }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setActionError(data.error || `Warm failed: ${res.status}`);
|
||||
setTimeout(() => setActionError(null), 3000);
|
||||
}
|
||||
} catch {
|
||||
setActionError('Network error');
|
||||
setTimeout(() => setActionError(null), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnload = async (confirmed: boolean) => {
|
||||
try {
|
||||
const res = await fetch('/api/control/action/submit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'unload',
|
||||
providerId: model.model.split(':')[0],
|
||||
model: model.model,
|
||||
confirmed,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (data.requiresConfirmation) {
|
||||
setConfirmUnload(true);
|
||||
return;
|
||||
}
|
||||
setActionError(data.error || `Unload failed: ${res.status}`);
|
||||
setTimeout(() => setActionError(null), 3000);
|
||||
} else {
|
||||
setConfirmUnload(false);
|
||||
}
|
||||
} catch {
|
||||
setActionError('Network error');
|
||||
setTimeout(() => setActionError(null), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmedUnload = async () => {
|
||||
await handleUnload(true);
|
||||
setConfirmUnload(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
layout
|
||||
initial={reducedMotion ? undefined : { scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
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',
|
||||
'border border-border/40 bg-muted/30',
|
||||
'font-medium',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'w-1.5 h-1.5 rounded-full shrink-0',
|
||||
stateConfig.bg,
|
||||
)}
|
||||
/>
|
||||
<span className="truncate max-w-[160px]">{model.model}</span>
|
||||
{model.inflight > 0 && (
|
||||
<span className="text-[10px] text-muted-foreground ml-0.5">
|
||||
({model.inflight})
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Action buttons — fire-and-forget, UI updates from control_fleet delta */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleWarm}
|
||||
className="p-0.5 rounded hover:bg-muted/50 text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={`Warm ${model.model}`}
|
||||
>
|
||||
<Play className="size-2.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleUnload(false)}
|
||||
className="p-0.5 rounded hover:bg-muted/50 text-muted-foreground hover:text-red-400 transition-colors"
|
||||
title={`Unload ${model.model}`}
|
||||
>
|
||||
<Eraser className="size-2.5" />
|
||||
</button>
|
||||
|
||||
{actionError && (
|
||||
<span className="text-[9px] text-red-400 absolute -top-4 left-0 whitespace-nowrap">
|
||||
{actionError}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{confirmUnload && (
|
||||
<div className="absolute top-full left-0 mt-1 z-10 bg-background border border-border rounded-md p-2 shadow-lg flex flex-col gap-1 min-w-[180px]">
|
||||
<p className="text-[11px] text-foreground">
|
||||
Model has active requests. Force unload?
|
||||
</p>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirmedUnload}
|
||||
className="px-2 py-0.5 text-[10px] rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors"
|
||||
>
|
||||
Force unload
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmUnload(false)}
|
||||
className="px-2 py-0.5 text-[10px] rounded bg-muted/30 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user