Open-source release of MAESTRO, an agent orchestration platform that runs LLM-driven tasks through sandboxed tools, with a web UI. Apache-2.0. See README.md and docs/ (getting-started, configuration, architecture).
132 lines
5.3 KiB
TypeScript
132 lines
5.3 KiB
TypeScript
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 <div className="text-xs text-slate-500 p-3">読み込み中...</div>;
|
||
if (isUnavailable) {
|
||
return (
|
||
<div className="text-xs text-slate-500 p-3">
|
||
node-status registry が未構成です。<br />
|
||
config.yaml の provider.workers を確認してください。
|
||
</div>
|
||
);
|
||
}
|
||
if (isError) return <div className="text-xs text-red-600 p-3">取得に失敗しました</div>;
|
||
if (nodes.length === 0) {
|
||
return (
|
||
<div className="text-xs text-slate-500 p-3">
|
||
ノードが登録されていません。<br />
|
||
config.yaml の provider.workers を確認してください。
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-1 p-2 overflow-auto h-full">
|
||
{nodes.map((n) => (
|
||
<NodeRow key={`${n.workerId}|${n.nodeId}`} node={n} />
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-surface-2">
|
||
<div className="w-8 h-8 flex-shrink-0 flex items-center justify-center">
|
||
{showPet ? (
|
||
<PetSprite
|
||
name={pet.pet!.name}
|
||
imageUrl={pet.imageUrl}
|
||
frameWidth={pet.frameWidth}
|
||
frameHeight={pet.frameHeight}
|
||
gridCols={pet.gridCols}
|
||
gridRows={pet.gridRows}
|
||
framesPerRow={framesPerRow}
|
||
state={petState}
|
||
size={32}
|
||
reducedMotion={reducedMotion}
|
||
/>
|
||
) : (
|
||
<span className={`text-base ${color}`} aria-label={label}>{icon}</span>
|
||
)}
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="text-xs font-medium text-slate-800 truncate flex items-center gap-1.5">
|
||
{node.nodeId}
|
||
{node.source === 'proxy' && (
|
||
<span className="text-[9px] uppercase tracking-wide text-slate-400">via {node.workerId}</span>
|
||
)}
|
||
</div>
|
||
<div className="text-[10px] text-slate-500 font-mono truncate">
|
||
{node.loadedModel ?? '-'}
|
||
{node.lastProbeError && (
|
||
<span className="text-rose-500 ml-1" title={node.lastProbeError}>(probe error)</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-col items-end text-[11px] font-mono leading-tight">
|
||
<div className="flex items-center gap-1">
|
||
<span className={color}>{icon}</span>
|
||
{node.totalSlots > 0
|
||
? <span className="text-slate-600">{node.busySlots}/{node.totalSlots}</span>
|
||
: <span className="text-slate-400">-</span>}
|
||
</div>
|
||
<span className="text-slate-400">
|
||
{node.throughputTps != null ? `${node.throughputTps.toFixed(0)} tok/s` : ' '}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|