feat: alive UI wave, galaxy presence, spread and fleet enhancements
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Dashboard ambient layer, comrade presence, Mission Deck and War Room, Emberwake supply chain, spread/docs publishing, fleet policy and modules API, CI docker mining, and refreshed USB pack.
This commit is contained in:
@@ -90,6 +90,11 @@ export default function AgentListItem({
|
||||
{agent.platform}{agent.arch ? `/${agent.arch}` : ''}
|
||||
</span>
|
||||
)}
|
||||
{agent.campaign && (
|
||||
<span className="agent-tag-chip" title="Spread campaign (?c=)">
|
||||
c:{agent.campaign}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
<span className={`status-badge ${agent.status}`}>{agent.status}</span>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useModalAmbientDuck } from '../../context/AmbientMusicContext';
|
||||
import { FLEET_GROUP_COLORS, normalizeGroupColor } from '../../help/fleetGroups';
|
||||
import './CreateGroupModal.css';
|
||||
|
||||
@@ -10,6 +11,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function CreateGroupModal({ open, agentCount, onClose, onCreate }: Props) {
|
||||
useModalAmbientDuck(open);
|
||||
const [name, setName] = useState('');
|
||||
const [color, setColor] = useState<string>(FLEET_GROUP_COLORS[0]);
|
||||
|
||||
|
||||
158
server/web/src/components/Fleet/FleetHeatMiniMap.css
Normal file
158
server/web/src/components/Fleet/FleetHeatMiniMap.css
Normal file
@@ -0,0 +1,158 @@
|
||||
/* ── Fleet heat mini-map (Crucible sidebar) ─────────────────────────────── */
|
||||
|
||||
.fleet-heat-minimap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.fleet-heat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.fleet-heat-count {
|
||||
margin-left: auto;
|
||||
color: var(--neon-cyan);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.fleet-heat-canvas {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
min-height: 160px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(0, 245, 255, 0.18);
|
||||
background:
|
||||
radial-gradient(ellipse at 50% 45%, rgba(0, 245, 255, 0.06) 0%, transparent 65%),
|
||||
rgba(0, 0, 0, 0.45);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fleet-heat-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(0, 245, 255, 0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 245, 255, 0.04) 1px, transparent 1px);
|
||||
background-size: 20% 20%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fleet-heat-dot {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: default;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.fleet-heat-dot--agent {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--dot-color, var(--neon-cyan));
|
||||
box-shadow: 0 0 6px var(--dot-color, var(--neon-cyan));
|
||||
cursor: pointer;
|
||||
transition: transform 0.12s, box-shadow 0.12s, opacity 0.12s;
|
||||
}
|
||||
|
||||
.fleet-heat-dot--agent:hover {
|
||||
transform: translate(-50%, -50%) scale(1.35);
|
||||
box-shadow: 0 0 12px var(--dot-color, var(--neon-cyan));
|
||||
}
|
||||
|
||||
.fleet-heat-dot--agent.offline {
|
||||
opacity: 0.35;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.fleet-heat-dot--agent.selected {
|
||||
transform: translate(-50%, -50%) scale(1.45);
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(255, 255, 255, 0.35),
|
||||
0 0 14px var(--dot-color, var(--neon-cyan));
|
||||
}
|
||||
|
||||
.fleet-heat-dot--agent.spike-pulse {
|
||||
animation: fleet-heat-spike 1.1s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fleet-heat-spike {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
box-shadow: 0 0 4px var(--dot-color, var(--neon-cyan));
|
||||
}
|
||||
35% {
|
||||
transform: translate(-50%, -50%) scale(2.2);
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(255, 255, 255, 0.25),
|
||||
0 0 22px var(--dot-color, var(--neon-cyan)),
|
||||
0 0 36px rgba(0, 245, 255, 0.55);
|
||||
}
|
||||
100% {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
box-shadow: 0 0 6px var(--dot-color, var(--neon-cyan));
|
||||
}
|
||||
}
|
||||
|
||||
.fleet-heat-dot--comrade {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--dot-color, #ffb020);
|
||||
box-shadow: 0 0 8px rgba(255, 176, 32, 0.85);
|
||||
border: 1px solid rgba(255, 220, 120, 0.7);
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fleet-heat-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 0.75rem;
|
||||
font-size: 0.62rem;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.fleet-heat-legend > span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.fleet-heat-legend-dot {
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fleet-heat-legend-dot.agent {
|
||||
background: var(--neon-cyan);
|
||||
box-shadow: 0 0 4px var(--neon-cyan);
|
||||
}
|
||||
|
||||
.fleet-heat-legend-dot.comrade {
|
||||
background: #ffb020;
|
||||
box-shadow: 0 0 4px #ffb020;
|
||||
}
|
||||
|
||||
.fleet-heat-legend-dot.pulse {
|
||||
background: var(--neon-cyan);
|
||||
animation: fleet-heat-spike 1.1s ease-out infinite;
|
||||
}
|
||||
|
||||
.fleet-heat-empty {
|
||||
margin: 0;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
137
server/web/src/components/Fleet/FleetHeatMiniMap.tsx
Normal file
137
server/web/src/components/Fleet/FleetHeatMiniMap.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { Agent } from '../../types';
|
||||
import type { FleetGroup } from '../../help/fleetGroups';
|
||||
import { formatHashrate } from '../../help/fleetFilters';
|
||||
import { usePresence } from '../../context/PresenceContext';
|
||||
import {
|
||||
agentAccentColor,
|
||||
COMRADE_DOT_COLOR,
|
||||
hashrateSpiked,
|
||||
layoutAgentPoints,
|
||||
layoutComradePoints,
|
||||
} from '../../help/fleetHeatMap';
|
||||
import './FleetHeatMiniMap.css';
|
||||
|
||||
interface FleetHeatMiniMapProps {
|
||||
agents: Agent[];
|
||||
groups: FleetGroup[];
|
||||
allIds: string[];
|
||||
selectedIds: Set<string>;
|
||||
onSelectAgent: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function FleetHeatMiniMap({
|
||||
agents,
|
||||
groups,
|
||||
allIds,
|
||||
selectedIds,
|
||||
onSelectAgent,
|
||||
}: FleetHeatMiniMapProps) {
|
||||
const { comrades } = usePresence();
|
||||
const prevHashrateRef = useRef<Record<string, number>>({});
|
||||
const [spikingIds, setSpikingIds] = useState<Set<string>>(() => new Set());
|
||||
|
||||
useEffect(() => {
|
||||
const spikes = new Set<string>();
|
||||
for (const agent of agents) {
|
||||
const prev = prevHashrateRef.current[agent.id];
|
||||
const current = agent.hashrate_15s ?? 0;
|
||||
if (hashrateSpiked(prev, current)) spikes.add(agent.id);
|
||||
prevHashrateRef.current[agent.id] = current;
|
||||
}
|
||||
if (spikes.size === 0) return;
|
||||
setSpikingIds(spikes);
|
||||
const t = setTimeout(() => setSpikingIds(new Set()), 1200);
|
||||
return () => clearTimeout(t);
|
||||
}, [agents]);
|
||||
|
||||
const agentPoints = useMemo(() => layoutAgentPoints(agents, groups), [agents, groups]);
|
||||
const comradePoints = useMemo(
|
||||
() => layoutComradePoints(comrades.map((c) => c.user)),
|
||||
[comrades],
|
||||
);
|
||||
|
||||
const onlineCount = agents.filter((a) => a.status === 'online').length;
|
||||
const agentById = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]);
|
||||
|
||||
return (
|
||||
<div className="fleet-heat-minimap">
|
||||
<div className="fleet-heat-header font-tech">
|
||||
<span className="section-ornament">◆</span>
|
||||
FLEET HEAT
|
||||
<span className="fleet-heat-count">
|
||||
{onlineCount}/{agents.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{agents.length === 0 ? (
|
||||
<p className="fleet-heat-empty">No nodes yet — deploy a build to see the map.</p>
|
||||
) : (
|
||||
<div
|
||||
className="fleet-heat-canvas"
|
||||
role="img"
|
||||
aria-label={`Fleet heat map with ${agents.length} agents and ${comrades.length} online comrades`}
|
||||
>
|
||||
<div className="fleet-heat-grid" aria-hidden />
|
||||
|
||||
{agentPoints.map((pt) => {
|
||||
const agent = agentById.get(pt.id);
|
||||
if (!agent) return null;
|
||||
const selected = selectedIds.has(pt.id);
|
||||
const color = agentAccentColor(pt.id, allIds, pt.color);
|
||||
const pulsing = spikingIds.has(pt.id);
|
||||
const hr = agent.hashrate_15s ?? 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={pt.id}
|
||||
type="button"
|
||||
className={[
|
||||
'fleet-heat-dot',
|
||||
'fleet-heat-dot--agent',
|
||||
pt.online ? '' : 'offline',
|
||||
selected ? 'selected' : '',
|
||||
pulsing ? 'spike-pulse' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={{ left: `${pt.x}%`, top: `${pt.y}%`, '--dot-color': color } as React.CSSProperties}
|
||||
title={`${pt.label} · ${agent.status}${hr > 0 ? ` · ${formatHashrate(hr)}` : ''}`}
|
||||
aria-label={`Select ${pt.label}`}
|
||||
aria-pressed={selected}
|
||||
onClick={() => onSelectAgent(pt.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{comradePoints.map((pt) => (
|
||||
<span
|
||||
key={pt.id}
|
||||
className="fleet-heat-dot fleet-heat-dot--comrade"
|
||||
style={
|
||||
{ left: `${pt.x}%`, top: `${pt.y}%`, '--dot-color': COMRADE_DOT_COLOR } as React.CSSProperties
|
||||
}
|
||||
title={`Operator ${pt.label} online`}
|
||||
aria-label={`Comrade ${pt.label}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="fleet-heat-legend font-tech">
|
||||
<span>
|
||||
<i className="fleet-heat-legend-dot agent" aria-hidden />
|
||||
Agents
|
||||
</span>
|
||||
<span>
|
||||
<i className="fleet-heat-legend-dot comrade" aria-hidden />
|
||||
Comrades
|
||||
</span>
|
||||
<span>
|
||||
<i className="fleet-heat-legend-dot pulse" aria-hidden />
|
||||
Hash spike
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -185,7 +185,7 @@ export function FleetHealthCard({ health }: { health: FleetHealth }) {
|
||||
: '#ff3c50';
|
||||
|
||||
return (
|
||||
<NeonCard accent={accent as any} className="fleet-health-card" hud>
|
||||
<NeonCard accent={accent as any} className="fleet-health-card operator-deck-card operator-interactive" hud>
|
||||
<div className="fh-header">
|
||||
<div>
|
||||
<span className="fh-label font-tech">FLEET HEALTH</span>
|
||||
|
||||
73
server/web/src/components/Fleet/FleetRuntimePanel.css
Normal file
73
server/web/src/components/Fleet/FleetRuntimePanel.css
Normal file
@@ -0,0 +1,73 @@
|
||||
.fleet-policy-deck {
|
||||
border: 1px solid rgba(74, 222, 128, 0.25);
|
||||
box-shadow: 0 0 24px rgba(74, 222, 128, 0.08);
|
||||
}
|
||||
|
||||
.fleet-policy-eyebrow {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.12em;
|
||||
color: rgba(74, 222, 128, 0.85);
|
||||
}
|
||||
|
||||
.fleet-policy-steps {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.fleet-policy-step-panel {
|
||||
margin-top: 1rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.fleet-policy-step-title {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fleet-policy-step-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.fleet-policy-review {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.fleet-policy-review p {
|
||||
margin: 0.35rem 0;
|
||||
}
|
||||
|
||||
.fleet-policy-ack-banner {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
margin: 0.75rem 0;
|
||||
border-radius: 10px;
|
||||
background: rgba(74, 222, 128, 0.08);
|
||||
border: 1px solid rgba(74, 222, 128, 0.35);
|
||||
}
|
||||
|
||||
.fleet-policy-ack-count {
|
||||
font-size: 2.5rem;
|
||||
line-height: 1;
|
||||
color: var(--neon-green, #6f6);
|
||||
text-shadow: 0 0 16px rgba(74, 222, 128, 0.35);
|
||||
}
|
||||
|
||||
.fleet-policy-ack-label {
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.fleet-module-deck {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
444
server/web/src/components/Fleet/FleetRuntimePanel.tsx
Normal file
444
server/web/src/components/Fleet/FleetRuntimePanel.tsx
Normal file
@@ -0,0 +1,444 @@
|
||||
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 './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</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</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user