import { useNodeStatus, type NodeStatus } from '../../hooks/useNodeStatus'; import { useNodeAnimationState } from '../../hooks/useNodeAnimationState'; import { useActivePet } from '../../hooks/useActivePet'; import { usePetFrameAnalysis } from '../../hooks/usePetFrameAnalysis'; import { PetSprite } from '../pets/PetSprite'; /** * Side Info Panel widget that surfaces the BackendStatusRegistry feed: * for every direct worker and every proxy backend, one row with Pet * sprite, status icon, slots, model, throughput. * * Polling cadence matches the server-side registry tick (5s) so the * client cache stays roughly aligned with the cache the server is * already maintaining — see hooks/useNodeStatus for the rationale. */ export function NodeStatusWidget() { const { nodes, isLoading, isError, isUnavailable } = useNodeStatus(); if (isLoading) return
読み込み中...
; if (isUnavailable) { return (
node-status registry が未構成です。
config.yaml の provider.workers を確認してください。
); } if (isError) return
取得に失敗しました
; if (nodes.length === 0) { return (
ノードが登録されていません。
config.yaml の provider.workers を確認してください。
); } return (
{nodes.map((n) => ( ))}
); } function statusEmoji(node: NodeStatus): { icon: string; label: string; color: string } { if (!node.online) return { icon: '⚫', label: 'offline', color: 'text-slate-500' }; if (node.totalSlots > 0 && node.busySlots >= node.totalSlots) { return { icon: '🔴', label: 'full', color: 'text-rose-600' }; } if (node.busySlots > 0) return { icon: '🟡', label: 'busy', color: 'text-amber-600' }; return { icon: '🟢', label: 'idle', color: 'text-emerald-600' }; } function usePrefersReducedMotion(): boolean { if (typeof window === 'undefined' || !window.matchMedia) return false; return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } function NodeRow({ node }: { node: NodeStatus }) { // The Pet selection logic in useActivePet already prefers // workerPets[backendId] over workerPets[workerId]; passing nodeId as // the "backend" argument hands the resolver the most specific key. const { data: pet } = useActivePet(node.workerId, node.nodeId); const framesPerRow = usePetFrameAnalysis( pet?.spriteUrl ?? null, pet?.gridCols ?? null, pet?.gridRows ?? null, ); const systemReducedMotion = usePrefersReducedMotion(); const petReducedMotion = (pet?.settings as { reducedMotion?: boolean } | undefined)?.reducedMotion ?? false; const reducedMotion = petReducedMotion || systemReducedMotion; const { icon, label, color } = statusEmoji(node); // Phase C: derive the animation state through useNodeAnimationState so // both this widget and the ChatPetOverlay see the same idle/running // decision against the registry feed. The hook reads the shared // useNodeStatus query (React Query dedups), so N rows here don't // multiply polling traffic. const petState = useNodeAnimationState(node.nodeId); const showPet = pet?.pet && pet.imageUrl; return (
{showPet ? ( ) : ( {icon} )}
{node.nodeId} {node.source === 'proxy' && ( via {node.workerId} )}
{node.loadedModel ?? '-'} {node.lastProbeError && ( (probe error) )}
{icon} {node.totalSlots > 0 ? {node.busySlots}/{node.totalSlots} : -}
{node.throughputTps != null ? `${node.throughputTps.toFixed(0)} tok/s` : ' '}
); }