Final sweep: Crucible fixes, Path Tracer polish, forge progress, tests green.
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
Align dashboard subtitle default and UpsertAgent tests with fleet label behavior; WebSocket coalesce and PathForge hardening; Crucible expanded ops and visual DV fixes; Vitest 610/610 and full test-suite pass; trim PROBLEMS.md to open items only.
This commit is contained in:
@@ -320,9 +320,24 @@ export default function AgentRemoteActions({
|
||||
<div className="compact-offline-banner">⚠ offline — commands disabled</div>
|
||||
)}
|
||||
<div className="agent-remote-row">
|
||||
<button type="button" aria-label="Screenshot" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('screenshot')} title="Capture desktop">📷</button>
|
||||
<button type="button" aria-label="Pause" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('pause')} title="Pause miner">⏸</button>
|
||||
<button type="button" aria-label="Resume" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('resume')} title="Resume miner">▶</button>
|
||||
<button type="button" aria-label="Screenshot" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('screenshot')} title="Capture desktop">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden style={{ width: '1em', height: '1em' }}>
|
||||
<rect x="3" y="6" width="18" height="14" rx="2" />
|
||||
<circle cx="12" cy="13" r="3" />
|
||||
<path d="M9 6l1.5-2h3L15 6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" aria-label="Pause" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('pause')} title="Pause miner">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden style={{ width: '1em', height: '1em' }}>
|
||||
<rect x="6" y="5" width="4" height="14" rx="1" />
|
||||
<rect x="14" y="5" width="4" height="14" rx="1" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" aria-label="Resume" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('resume')} title="Resume miner">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden style={{ width: '1em', height: '1em' }}>
|
||||
<path d="M6 4l14 8-14 8V4z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" aria-label="Reboot" className="agent-action-btn warn" disabled={!isOnline || !!busy} onClick={() => dispatch('reboot_machine')} title="Reboot machine">↺</button>
|
||||
<button type="button" aria-label="Shutdown" className="agent-action-btn warn" disabled={!isOnline || !!busy} onClick={() => dispatch('shutdown_machine')} title="Shutdown machine">⏻</button>
|
||||
<button type="button" aria-label="Wake" className="agent-action-btn" disabled={!!busy} onClick={() => dispatch('wol', { mac: agent?.mac_address })} title="Wake on LAN">☀</button>
|
||||
@@ -748,7 +763,12 @@ export default function AgentRemoteActions({
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={isOnline ? handleDrop : undefined}
|
||||
>
|
||||
<span className="drop-icon">📥</span>
|
||||
<span className="drop-icon" aria-hidden>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ width: '1.5em', height: '1.5em' }}>
|
||||
<path d="M12 3v12m0 0l-4-4m4 4l4-4" />
|
||||
<path d="M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
|
||||
</svg>
|
||||
</span>
|
||||
<p>Drag & Drop file here</p>
|
||||
<small>Pushes to user Desktop (any OS)</small>
|
||||
<button
|
||||
|
||||
@@ -128,7 +128,10 @@ export default function CrucibleExpandedOps({
|
||||
|
||||
const bulkDispatch = useCallback(
|
||||
(action: string, args: Record<string, unknown> = {}, tgts = targets) => {
|
||||
if (tgts.length === 0) return;
|
||||
if (tgts.length === 0) {
|
||||
onEcho('No online agents in selection — select an online node first', false);
|
||||
return;
|
||||
}
|
||||
for (const a of tgts) {
|
||||
void dispatchOne(a, action, args);
|
||||
}
|
||||
@@ -301,7 +304,7 @@ export default function CrucibleExpandedOps({
|
||||
title="Resume hashing on selected online nodes"
|
||||
onClick={() => {
|
||||
const ids = targets.map((a) => a.id);
|
||||
if (ids.length === 0) return;
|
||||
if (ids.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
|
||||
api.sendBulkCommand(ids, 'resume').then((r) => onEcho(`resume → sent:${r.sent} failed:${r.failed}`, true)).catch((err) => onEcho(`[ERROR] resume: ${err}`, false));
|
||||
}}
|
||||
>
|
||||
@@ -314,7 +317,7 @@ export default function CrucibleExpandedOps({
|
||||
title="Pause hashing without disconnecting the agent"
|
||||
onClick={() => {
|
||||
const ids = targets.map((a) => a.id);
|
||||
if (ids.length === 0) return;
|
||||
if (ids.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
|
||||
api.sendBulkCommand(ids, 'pause').then((r) => onEcho(`pause → sent:${r.sent} failed:${r.failed}`, true)).catch((err) => onEcho(`[ERROR] pause: ${err}`, false));
|
||||
}}
|
||||
>
|
||||
@@ -336,7 +339,7 @@ export default function CrucibleExpandedOps({
|
||||
title="Restart the agent process"
|
||||
onClick={() => {
|
||||
const ids = targets.map((a) => a.id);
|
||||
if (ids.length === 0) return;
|
||||
if (ids.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
|
||||
api.sendBulkCommand(ids, 'restart').then((r) => onEcho(`restart → sent:${r.sent} failed:${r.failed}`, true)).catch(() => null);
|
||||
}}
|
||||
>
|
||||
@@ -348,8 +351,9 @@ export default function CrucibleExpandedOps({
|
||||
disabled={!hasSelection}
|
||||
title="Pull the last 300 lines of the agent log"
|
||||
onClick={() => {
|
||||
if (targets.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
|
||||
targets.forEach((a) => api.sendAgentCommand(a.id, 'get_log', { tail_lines: 300 }).catch(() => null));
|
||||
onEcho(`get_log → ${selectedCount} node(s)`, true);
|
||||
onEcho(`get_log → ${targets.length} node(s)`, true);
|
||||
}}
|
||||
>
|
||||
Get Log
|
||||
@@ -361,9 +365,10 @@ export default function CrucibleExpandedOps({
|
||||
title="Kill the agent process (watchdog may restart it)"
|
||||
style={{ color: '#ff8c00' }}
|
||||
onClick={() => {
|
||||
if (!window.confirm(`Kill agent process on ${selectedCount} node(s)?`)) return;
|
||||
if (targets.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
|
||||
if (!window.confirm(`Kill agent process on ${targets.length} online node(s)?`)) return;
|
||||
targets.forEach((a) => api.sendAgentCommand(a.id, 'stop').catch(() => null));
|
||||
onEcho(`kill → ${selectedCount} node(s)`, true);
|
||||
onEcho(`kill → ${targets.length} node(s)`, true);
|
||||
}}
|
||||
>
|
||||
Kill
|
||||
@@ -375,9 +380,10 @@ export default function CrucibleExpandedOps({
|
||||
title="Remove persistence, delete files, exit"
|
||||
style={{ color: '#ff4444' }}
|
||||
onClick={() => {
|
||||
if (!window.confirm(`UNINSTALL from ${selectedCount} node(s)? This removes persistence and deletes all agent files.`)) return;
|
||||
if (targets.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
|
||||
if (!window.confirm(`UNINSTALL from ${targets.length} online node(s)? This removes persistence and deletes all agent files.`)) return;
|
||||
targets.forEach((a) => api.sendAgentCommand(a.id, 'uninstall').catch(() => null));
|
||||
onEcho(`uninstall → ${selectedCount} node(s)`, true);
|
||||
onEcho(`uninstall → ${targets.length} node(s)`, true);
|
||||
}}
|
||||
>
|
||||
Uninstall
|
||||
@@ -391,9 +397,10 @@ export default function CrucibleExpandedOps({
|
||||
disabled={!hasSelection}
|
||||
title="OS reboot"
|
||||
onClick={() => {
|
||||
if (!window.confirm(`Reboot ${selectedCount} machine(s)?`)) return;
|
||||
if (targets.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
|
||||
if (!window.confirm(`Reboot ${targets.length} online machine(s)?`)) return;
|
||||
targets.forEach((a) => api.sendAgentCommand(a.id, 'reboot_machine').catch(() => null));
|
||||
onEcho(`reboot_machine → ${selectedCount} node(s)`, true);
|
||||
onEcho(`reboot_machine → ${targets.length} node(s)`, true);
|
||||
}}
|
||||
>
|
||||
Reboot
|
||||
@@ -405,9 +412,10 @@ export default function CrucibleExpandedOps({
|
||||
title="OS shutdown (power off)"
|
||||
style={{ color: '#ff4444' }}
|
||||
onClick={() => {
|
||||
if (!window.confirm(`Shutdown ${selectedCount} machine(s)?`)) return;
|
||||
if (targets.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
|
||||
if (!window.confirm(`Shutdown ${targets.length} online machine(s)?`)) return;
|
||||
targets.forEach((a) => api.sendAgentCommand(a.id, 'shutdown_machine').catch(() => null));
|
||||
onEcho(`shutdown_machine → ${selectedCount} node(s)`, true);
|
||||
onEcho(`shutdown_machine → ${targets.length} node(s)`, true);
|
||||
}}
|
||||
>
|
||||
Shutdown
|
||||
|
||||
@@ -14,6 +14,7 @@ import { getSetupStatus } from '../../help/setupStatus';
|
||||
import { resolvePageWeather } from '../../help/pageWeather';
|
||||
import { api } from '../../api/client';
|
||||
import { usePresence } from '../../context/PresenceContext';
|
||||
import { useVisualEffects } from '../../context/VisualEffectsContext';
|
||||
import ComradeAvatar from '../Presence/ComradeAvatar';
|
||||
import type { ServerConfig, ServerInfo } from '../../types';
|
||||
import '../Presence/Presence.css';
|
||||
@@ -42,20 +43,20 @@ const NAV = [
|
||||
{ to: '/dashboard', label: 'Command Deck', icon: 'deck' },
|
||||
{ to: '/agents', label: 'Fleet Roster', icon: 'fleet' },
|
||||
{ to: '/crucible', label: 'Crucible', icon: 'crucible' },
|
||||
{ to: '/pathtracer', label: 'Path Tracer', icon: 'trace' },
|
||||
{ to: '/forge', label: 'Forge', icon: 'forge' },
|
||||
{ to: '/mission-deck', label: 'Mission Deck', icon: 'mission', glow: true },
|
||||
{ to: '/builds', label: 'Builds', icon: 'builds' },
|
||||
{ to: '/emberwake', label: 'Emberwake', icon: 'ember' },
|
||||
{ to: '/settings', label: 'Calibrate', icon: 'gear' },
|
||||
{ to: '/pathtracer', label: 'Path Tracer', icon: 'trace' },
|
||||
] as const;
|
||||
|
||||
const DOCS_HREF = '/docs/';
|
||||
|
||||
/** Primary tabs on iPhone bottom bar */
|
||||
const MOBILE_PRIMARY = NAV.slice(0, 4);
|
||||
/** Builds, Calibrate, Path Tracer — “More” sheet */
|
||||
const MOBILE_MORE = NAV.slice(4);
|
||||
/** Primary tabs on mobile bottom bar — Deck, Fleet, Crucible, Path Tracer, Forge */
|
||||
const MOBILE_PRIMARY = NAV.slice(0, 5);
|
||||
/** Mission Deck, Builds, Emberwake, Calibrate — “More” sheet */
|
||||
const MOBILE_MORE = NAV.slice(5);
|
||||
|
||||
function NavIcon({ type }: { type: string }) {
|
||||
switch (type) {
|
||||
@@ -224,6 +225,7 @@ export default function Layout({ children }: LayoutProps) {
|
||||
const location = useLocation();
|
||||
const isMobile = useIsMobileLayout();
|
||||
const { othersOnline, comrades } = usePresence();
|
||||
const { glowParticles } = useVisualEffects();
|
||||
const [serverConfig, setServerConfig] = useState<ServerConfig | null>(null);
|
||||
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
@@ -257,6 +259,7 @@ export default function Layout({ children }: LayoutProps) {
|
||||
'/dashboard': 'Deck',
|
||||
'/agents': 'Fleet',
|
||||
'/crucible': 'Ops',
|
||||
'/pathtracer': 'Tracer',
|
||||
'/forge': 'Forge',
|
||||
};
|
||||
|
||||
@@ -265,9 +268,9 @@ export default function Layout({ children }: LayoutProps) {
|
||||
className={`layout${isMobile ? ' layout--mobile' : ''}${othersOnline ? ' layout--comrades-online' : ''}`}
|
||||
data-operator-deck={operatorDeckId(location.pathname)}
|
||||
>
|
||||
{!isMobile && <CursorFire />}
|
||||
{!isMobile && glowParticles && <CursorFire />}
|
||||
<AmbientBackground weather={pageWeather} />
|
||||
<SacredGeometryLayer />
|
||||
{glowParticles && <SacredGeometryLayer />}
|
||||
<nav className="sidebar sidebar--desktop desktop-only">
|
||||
<div className="sidebar-header">
|
||||
<div className="logo">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useVisibleInterval } from '../../hooks/usePageVisible';
|
||||
import { api } from '../../api/client';
|
||||
import { useWebSocket } from '../../hooks/useWebSocket';
|
||||
import ComradeIndicators from '../Presence/ComradeIndicators';
|
||||
import { usePresence } from '../../context/PresenceContext';
|
||||
import '../Presence/Presence.css';
|
||||
@@ -8,10 +9,14 @@ import './VisualComponents.css';
|
||||
|
||||
export default function SystemStatusBar() {
|
||||
const [serverOk, setServerOk] = useState(true);
|
||||
const [agentTotal, setAgentTotal] = useState(0);
|
||||
const [agentOnline, setAgentOnline] = useState(0);
|
||||
const [buildCount, setBuildCount] = useState(0);
|
||||
const { othersOnline } = usePresence();
|
||||
const { agents } = useWebSocket();
|
||||
|
||||
const { agentTotal, agentOnline } = useMemo(() => ({
|
||||
agentTotal: agents.length,
|
||||
agentOnline: agents.filter((a) => a.status === 'online').length,
|
||||
}), [agents]);
|
||||
|
||||
const poll = useCallback(async () => {
|
||||
try {
|
||||
@@ -20,14 +25,6 @@ export default function SystemStatusBar() {
|
||||
} catch {
|
||||
setServerOk(false);
|
||||
}
|
||||
try {
|
||||
const agents = await api.listAgents();
|
||||
setAgentTotal(agents.length);
|
||||
setAgentOnline(agents.filter((a) => a.status === 'online').length);
|
||||
} catch {
|
||||
setAgentTotal(0);
|
||||
setAgentOnline(0);
|
||||
}
|
||||
try {
|
||||
const builds = await api.listBuilds();
|
||||
setBuildCount(builds.length);
|
||||
@@ -64,9 +61,14 @@ export default function SystemStatusBar() {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="status-pill"
|
||||
style={{ textDecoration: 'none', color: 'var(--neon-cyan)' }}
|
||||
style={{ textDecoration: 'none', color: 'var(--neon-cyan)', display: 'inline-flex', alignItems: 'center', gap: '0.3em' }}
|
||||
>
|
||||
📖 DOCS
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ width: '0.95em', height: '0.95em', flexShrink: 0 }} aria-hidden>
|
||||
<path d="M4 19.5A2.5 2.5 0 016.5 17H20" />
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z" />
|
||||
<path d="M8 7h8M8 11h6" strokeOpacity="0.55" />
|
||||
</svg>
|
||||
DOCS
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -770,8 +770,18 @@ describe('SystemStatusBar', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(api, 'healthCheck').mockResolvedValue(undefined);
|
||||
vi.spyOn(api, 'listAgents').mockResolvedValue([mockAgent(), mockAgent({ id: 'a2', status: 'offline' })]);
|
||||
vi.spyOn(api, 'listBuilds').mockResolvedValue([{ id: 'b1' } as never]);
|
||||
useWebSocketMock.mockReturnValue({
|
||||
isConnected: true,
|
||||
agents: [mockAgent(), mockAgent({ id: 'a2', status: 'offline' })],
|
||||
recentShares: [],
|
||||
fleetAlerts: [],
|
||||
poolStatus: [],
|
||||
aiActivity: [],
|
||||
agentLogs: {},
|
||||
commandResults: [],
|
||||
latestMessage: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows server and fleet pills after poll', async () => {
|
||||
@@ -782,9 +792,9 @@ describe('SystemStatusBar', () => {
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/SERVER UP/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/FLEET 1\/2 ONLINE/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 BUILD/i)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/FLEET 1\/2 ONLINE/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 BUILD/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user