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` : ' '}
);
}