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).
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user