import { useEffect, useMemo, useState } from 'react'; import { api } from '../../api/client'; import { useModalAmbientDuck } from '../../context/AmbientMusicContext'; import { useWebSocket } from '../../hooks/useWebSocket'; import { useFleetGroups } from '../../hooks/useFleetGroups'; import type { FleetModuleManifest } from '../../types'; import NeonCard from '../NeonCard/NeonCard'; import { HelpTip } from '../HelpTip'; import './FleetRuntimePanel.css'; type TargetMode = 'all' | 'group'; type WizardStep = 1 | 2 | 3 | 4; const POLICY_STEPS: { step: WizardStep; label: string }[] = [ { step: 1, label: 'Pick target' }, { step: 2, label: 'Set policy' }, { step: 3, label: 'Confirm push' }, { step: 4, label: 'Live acks' }, ]; function stepStatus(current: WizardStep, step: WizardStep): 'done' | 'active' | 'pending' { if (step < current) return 'done'; if (step === current) return 'active'; return 'pending'; } export default function FleetRuntimePanel() { const { agents, policyAcks } = useWebSocket(); const { groups } = useFleetGroups(); const [modules, setModules] = useState([]); const [wizardStep, setWizardStep] = useState(1); const [targetMode, setTargetMode] = useState('all'); const [groupId, setGroupId] = useState(''); const [selectedModule, setSelectedModule] = useState('crucible_ops'); const [miningMode, setMiningMode] = useState('scheduled'); const [scheduleStart, setScheduleStart] = useState('22:00'); const [scheduleEnd, setScheduleEnd] = useState('06:00'); const [maxCpu, setMaxCpu] = useState(75); const [poolHost, setPoolHost] = useState(''); const [poolPort, setPoolPort] = useState(0); const [policyMsg, setPolicyMsg] = useState(''); const [moduleMsg, setModuleMsg] = useState(''); const [pushingPolicy, setPushingPolicy] = useState(false); const [pushingModule, setPushingModule] = useState(false); const [pushId, setPushId] = useState(null); const [expectedSent, setExpectedSent] = useState(0); useModalAmbientDuck(wizardStep === 3); useEffect(() => { api.listFleetModules().then(setModules).catch(() => setModules([])); }, []); const onlineCount = useMemo(() => agents.filter((a) => a.status === 'online').length, [agents]); const targetLabel = useMemo(() => { if (targetMode === 'all') return `All online (${onlineCount})`; const g = groups.find((x) => x.id === groupId); return g ? `${g.name} (${g.agentIds.length} agents)` : 'No group selected'; }, [targetMode, groupId, groups, onlineCount]); const ackCount = useMemo(() => { if (!pushId) return 0; const ids = new Set(); for (const ack of policyAcks) { if (ack.push_id === pushId && ack.agent_id) ids.add(ack.agent_id); } return ids.size; }, [policyAcks, pushId]); const resolveAgentIds = (): string[] => { if (targetMode === 'all') return ['all']; const g = groups.find((x) => x.id === groupId); if (!g || g.agentIds.length === 0) return []; return g.agentIds; }; const targetReady = targetMode === 'all' || (groupId !== '' && resolveAgentIds().length > 0); const handlePushPolicy = async () => { const agent_ids = resolveAgentIds(); if (agent_ids.length === 0) { setPolicyMsg('Select a group with agents or use All online.'); return; } setPushingPolicy(true); setPolicyMsg(''); try { const policy: Record = { mining_mode: miningMode, max_cpu_usage_pct: maxCpu, }; if (miningMode === 'scheduled') { policy.schedule_start = scheduleStart; policy.schedule_end = scheduleEnd; } if (poolHost.trim()) { policy.pool_host = poolHost.trim(); if (poolPort > 0) policy.pool_port = poolPort; } const res = await api.pushFleetPolicy({ agent_ids, policy }); if (res.success) { setPushId(res.push_id ?? null); setExpectedSent(res.sent ?? 0); setWizardStep(4); setPolicyMsg( `Policy dispatched to ${res.sent} agent(s)${res.failed ? ` (${res.failed} delivery failures)` : ''}. Waiting for live acks…`, ); } else { setPolicyMsg(res.error || 'No agents received the policy.'); } } catch (e) { setPolicyMsg(e instanceof Error ? e.message : 'Push failed'); } finally { setPushingPolicy(false); } }; const handlePushModule = async () => { const agent_ids = resolveAgentIds(); if (agent_ids.length === 0) { setModuleMsg('Select a group with agents or use All online.'); return; } setPushingModule(true); setModuleMsg(''); try { const res = await api.pushFleetModule({ agent_ids, module: selectedModule }); setModuleMsg( res.success ? `Module "${res.module}" queued for ${res.sent} agent(s).` : res.error || 'No agents received the module push.', ); } catch (e) { setModuleMsg(e instanceof Error ? e.message : 'Push failed'); } finally { setPushingModule(false); } }; const resetWizard = () => { setWizardStep(1); setPushId(null); setExpectedSent(0); setPolicyMsg(''); }; return ( <>

RUNTIME · NO RE-FORGE

Live Fleet Policy

Push live mining rules to connected workers — schedule, CPU cap, and optional pool overrides apply in memory via policy_update. Identity and baked forge options stay on the binary; this panel never replaces the Forge builder.

{POLICY_STEPS.map(({ step, label }) => { const status = stepStatus(wizardStep, step); return ( {status === 'done' ? '✓' : status === 'active' ? '●' : '○'} {step}. {label} ); })}
{wizardStep === 1 && (

Step 1 — Pick target

{targetMode === 'group' && (
)}
)} {wizardStep === 2 && (

Step 2 — Set policy

Target: {targetLabel}

setMaxCpu(parseInt(e.target.value, 10) || 75)} />
{miningMode === 'scheduled' && (
setScheduleStart(e.target.value)} />
setScheduleEnd(e.target.value)} />
)}
setPoolHost(e.target.value)} />
setPoolPort(parseInt(e.target.value, 10) || 0)} />
)} {wizardStep === 3 && (

Step 3 — Confirm push

Target: {targetLabel}

Mining: {miningMode} {miningMode === 'scheduled' ? ` · ${scheduleStart} → ${scheduleEnd}` : ''}

CPU cap: {maxCpu}%

Pool override:{' '} {poolHost.trim() ? `${poolHost.trim()}${poolPort > 0 ? `:${poolPort}` : ''}` : 'none (keep baked)'}

)} {wizardStep === 4 && (

Step 4 — Live acknowledgements

{ackCount} agent{ackCount === 1 ? '' : 's'} acknowledged {expectedSent > 0 ? ` · ${expectedSent} dispatched` : ''}
{pushId &&

push_id: {pushId}

} {policyMsg &&

{policyMsg}

}
)}

Push Module to Fleet

Stage signed feature packs from data/modules/ — agents fetch via{' '} GET /api/v1/agent/module/{name} and enable flags without a full re-forge. Uses the same target picker as Live Fleet Policy above.

{targetMode === 'group' && (
)}
{moduleMsg &&

{moduleMsg}

}
); }