Files
maestro/ui/src/components/dashboard/NodeStatusWidget.tsx
T
cladeandswallow 7049a874f3 feat: initial public release (MAESTRO v0.1.0)
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).
2026-06-03 04:01:14 +00:00

132 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}