feat: Tenable-style patch_status - pending_updates, last_patch, reboot_pending across full stack

This commit is contained in:
AetherForge
2026-05-30 23:11:32 -07:00
parent 9232f4c448
commit 4207f6c21b
43 changed files with 4359 additions and 180 deletions

View File

@@ -0,0 +1,215 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import AgentsPage from './AgentsPage';
import { mockAgent, mockServerInfo } from '../test/fixtures';
import { useWebSocket } from '../hooks/useWebSocket';
import { api } from '../api/client';
vi.mock('../hooks/useWebSocket', () => ({
useWebSocket: vi.fn(),
}));
const useWebSocketMock = vi.mocked(useWebSocket);
function wsValue(overrides: Partial<ReturnType<typeof useWebSocket>> = {}) {
return {
isConnected: false,
agents: [],
recentShares: [],
fleetAlerts: [],
poolStatus: [],
aiActivity: [],
agentLogs: {},
commandResults: [],
latestMessage: null,
...overrides,
};
}
function renderAgentsPage() {
return render(<AgentsPage />);
}
describe('AgentsPage', () => {
beforeEach(() => {
vi.clearAllMocks();
useWebSocketMock.mockReturnValue(wsValue());
vi.spyOn(api, 'listAgents').mockResolvedValue([]);
vi.spyOn(api, 'getServerInfo').mockResolvedValue(mockServerInfo);
vi.spyOn(api, 'getAgentStats').mockResolvedValue([]);
vi.spyOn(api, 'getAgentLog').mockResolvedValue({ agent_id: 'x', content: 'log line' });
vi.spyOn(api, 'updateAgentMeta').mockResolvedValue({
success: true,
agent: mockAgent({ notes: 'saved note', tags: ['rack-a'] }),
});
vi.spyOn(api, 'sendBulkCommand').mockResolvedValue({
success: true,
sent: 1,
failed: 0,
action: 'restart',
});
});
afterEach(() => {
cleanup();
});
it('renders page heading and quick deploy labels', async () => {
renderAgentsPage();
expect(screen.getByRole('heading', { level: 1, name: 'Fleet Roster' })).toBeInTheDocument();
expect(screen.getByText('FLEET REGISTRY')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('One-liner Quick Deploy')).toBeInTheDocument();
});
expect(screen.getByText('Install & run (auto-launches)')).toBeInTheDocument();
expect(screen.getByText('Direct download only (saves file)')).toBeInTheDocument();
expect(screen.getAllByText('Windows')).toHaveLength(2);
expect(screen.getByText('Linux/Mac')).toBeInTheDocument();
expect(screen.getByText('macOS')).toBeInTheDocument();
});
it('builds quick deploy URLs from server info', async () => {
renderAgentsPage();
await waitFor(() => {
expect(screen.getAllByText(`iex (irm '${mockServerInfo.suggested_url}/install.ps1')`)).toHaveLength(1);
});
expect(screen.getByText(`curl -sL ${mockServerInfo.suggested_url}/install.sh | bash`)).toBeInTheDocument();
expect(screen.getByText(`${mockServerInfo.suggested_url}/get?os=windows`)).toBeInTheDocument();
});
it('shows loading then empty multi-OS state', async () => {
renderAgentsPage();
expect(screen.getByText('Scanning network...')).toBeInTheDocument();
expect(await screen.findByText('No agents registered')).toBeInTheDocument();
expect(
screen.getByText(/Deploy a worker to any machine \(Windows, Linux, or macOS\)/)
).toBeInTheDocument();
});
it('surfaces listAgents load errors', async () => {
vi.spyOn(api, 'listAgents').mockRejectedValue(new Error('API unavailable'));
renderAgentsPage();
expect(await screen.findByText('API unavailable')).toBeInTheDocument();
});
it('lists agents and opens detail panel with section headings', async () => {
const agent = mockAgent({ name: 'Rack B Miner', notes: 'basement', tags: ['home'] });
vi.spyOn(api, 'listAgents').mockResolvedValue([agent]);
renderAgentsPage();
await waitFor(() => {
expect(screen.getByText('Rack B Miner')).toBeInTheDocument();
});
await userEvent.setup().click(screen.getByText('Rack B Miner'));
await waitFor(() => {
expect(screen.getByRole('heading', { level: 2, name: 'Rack B Miner' })).toBeInTheDocument();
});
expect(screen.getByText('Notes & Tags')).toBeInTheDocument();
expect(screen.getByText('Hashrate')).toBeInTheDocument();
expect(screen.getByText('Shares')).toBeInTheDocument();
expect(screen.getByText('Remote Control')).toBeInTheDocument();
expect(api.getAgentStats).toHaveBeenCalledWith(agent.id, 60);
});
it('preserves notes draft while typing until agent switch', async () => {
const a1 = mockAgent({ id: 'a1', name: 'Node One', notes: 'note one' });
const a2 = mockAgent({ id: 'a2', name: 'Node Two', notes: 'note two' });
vi.spyOn(api, 'listAgents').mockResolvedValue([a1, a2]);
renderAgentsPage();
await waitFor(() => expect(screen.getByText('Node One')).toBeInTheDocument());
const user = userEvent.setup();
await user.click(screen.getByText('Node One'));
const detail = await screen.findByRole('heading', { level: 2, name: 'Node One' });
const panel = detail.closest('.agent-detail') as HTMLElement;
const notes = within(panel).getByPlaceholderText('Notes about this machine…') as HTMLTextAreaElement;
await waitFor(() => expect(notes.value).toBe('note one'));
await user.clear(notes);
await user.type(notes, 'typing in progress');
expect(notes.value).toBe('typing in progress');
await user.click(screen.getByText('Node Two'));
await waitFor(() => expect(notes.value).toBe('note two'));
});
it('saves notes and tags via API', async () => {
const agent = mockAgent({ id: 'save-me', name: 'Save Target' });
vi.spyOn(api, 'listAgents').mockResolvedValue([agent]);
const updateSpy = vi.spyOn(api, 'updateAgentMeta').mockResolvedValue({
success: true,
agent: { ...agent, notes: 'Living room PC', tags: ['living-room'] },
});
renderAgentsPage();
await waitFor(() => expect(screen.getByText('Save Target')).toBeInTheDocument());
const user = userEvent.setup();
await user.click(screen.getByText('Save Target'));
const detail = await screen.findByRole('heading', { level: 2, name: 'Save Target' });
const panel = detail.closest('.agent-detail') as HTMLElement;
const notes = within(panel).getByPlaceholderText('Notes about this machine…');
await user.clear(notes);
await user.type(notes, 'Living room PC');
const tags = within(panel).getByPlaceholderText('Tags: living-room, rack-b (comma separated)');
await user.clear(tags);
await user.type(tags, 'living-room, rack-b');
await user.click(within(panel).getByRole('button', { name: 'Save notes & tags' }));
await waitFor(() => {
expect(updateSpy).toHaveBeenCalledWith('save-me', 'Living room PC', ['living-room', 'rack-b']);
});
expect(await within(panel).findByText('Saved')).toBeInTheDocument();
});
it('alerts when bulk action has no online agents', async () => {
const offline = mockAgent({ id: 'off-1', name: 'Offline Node', status: 'offline' });
vi.spyOn(api, 'listAgents').mockResolvedValue([offline]);
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
renderAgentsPage();
await waitFor(() => expect(screen.getByText('Offline Node')).toBeInTheDocument());
const list = screen.getByText('Offline Node').closest('.agents-list') as HTMLElement;
await userEvent.setup().click(within(list).getByRole('checkbox'));
await userEvent.setup().click(screen.getByRole('button', { name: 'Pause' }));
await waitFor(() => {
expect(alertSpy).toHaveBeenCalledWith('No online agents in selection.');
});
alertSpy.mockRestore();
});
it('alerts when bulk command API fails', async () => {
const agent = mockAgent({ name: 'Online One' });
vi.spyOn(api, 'listAgents').mockResolvedValue([agent]);
vi.spyOn(api, 'sendBulkCommand').mockRejectedValue(new Error('bulk failed'));
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
renderAgentsPage();
await waitFor(() => expect(screen.getByText('Online One')).toBeInTheDocument());
const list = screen.getByText('Online One').closest('.agents-list') as HTMLElement;
await userEvent.setup().click(within(list).getByRole('checkbox'));
await userEvent.setup().click(screen.getByRole('button', { name: 'Pause' }));
await waitFor(() => {
expect(alertSpy).toHaveBeenCalledWith('bulk failed');
});
alertSpy.mockRestore();
});
it('syncs agents from websocket when connected', async () => {
const restAgent = mockAgent({ id: 'rest', name: 'REST Name', hashrate_15m: 100 });
vi.spyOn(api, 'listAgents').mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve([restAgent]), 50))
);
const liveAgent = mockAgent({ id: 'rest', name: 'Live Name', hashrate_15m: 999 });
useWebSocketMock.mockReturnValue(wsValue({ isConnected: true, agents: [liveAgent] }));
renderAgentsPage();
await waitFor(() => {
expect(screen.getByText('Live Name')).toBeInTheDocument();
});
expect(screen.queryByText('REST Name')).not.toBeInTheDocument();
});
it('shows filter empty hint when no agents match', async () => {
vi.spyOn(api, 'listAgents').mockResolvedValue([mockAgent({ name: 'Hidden', tags: ['prod'] })]);
renderAgentsPage();
await waitFor(() => expect(screen.getByText('Hidden')).toBeInTheDocument());
const search = screen.getByPlaceholderText('Search name, IP, notes, tags…');
await userEvent.setup().type(search, 'nomatchxyz');
expect(screen.getByText('No agents match filters.')).toBeInTheDocument();
});
});

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { api } from '../api/client';
import { useWebSocket } from '../hooks/useWebSocket';
import type { Agent, HashrateSample, ServerInfo } from '../types';
@@ -93,13 +93,25 @@ export default function AgentsPage() {
const [tagsDraft, setTagsDraft] = useState('');
const [metaSaving, setMetaSaving] = useState(false);
const [metaMsg, setMetaMsg] = useState('');
const isConnectedRef = useRef(isConnected);
isConnectedRef.current = isConnected;
useEffect(() => {
let cancelled = false;
api.listAgents()
.then(setAgents)
.catch((err) => setLoadError(err instanceof Error ? err.message : 'Failed to load agents'))
.finally(() => setLoading(false));
.then((data) => {
if (!cancelled && !isConnectedRef.current) setAgents(data);
})
.catch((err) => {
if (!cancelled) setLoadError(err instanceof Error ? err.message : 'Failed to load agents');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
api.getServerInfo().then(setServerInfo).catch(() => {});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
@@ -214,6 +226,7 @@ export default function AgentsPage() {
await api.sendBulkCommand(onlineIds, action);
} catch (err) {
console.error(err);
alert(err instanceof Error ? err.message : 'Bulk command failed');
} finally {
setBulkBusy(false);
}

View File

@@ -150,10 +150,82 @@
margin-top: 1px;
}
.cn-badges {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 2px;
}
.cn-ssh.ssh-on { color: var(--neon-green); background: rgba(57,255,20,0.12); }
.cn-ssh.ssh-off { color: #ff4466; background: rgba(255,68,102,0.12); }
.cn-ssh.ssh-unk { color: var(--text-muted); background: rgba(255,255,255,0.06); }
.cn-posture {
font-size: 0.68rem;
font-family: var(--font-tech);
letter-spacing: 0.04em;
padding: 1px 5px;
border-radius: 3px;
}
.cn-posture.posture-good { color: var(--neon-green); background: rgba(57,255,20,0.1); }
.cn-posture.posture-warn { color: var(--neon-amber); background: rgba(255,176,32,0.12); }
.cn-posture.posture-bad { color: #ff4466; background: rgba(255,68,102,0.12); }
.cn-posture.posture-unk { color: var(--text-muted); background: rgba(255,255,255,0.06); }
.cn-patch {
font-size: 0.65rem;
font-family: var(--font-tech);
padding: 1px 5px;
border-radius: 3px;
}
.cn-patch.patch-ok { color: var(--neon-cyan); background: rgba(0,245,255,0.08); }
.cn-patch.patch-stale { color: var(--neon-amber); background: rgba(255,176,32,0.1); }
.cn-elevated {
font-size: 0.62rem;
font-family: var(--font-tech);
padding: 1px 4px;
border-radius: 3px;
color: var(--neon-magenta);
background: rgba(255,45,166,0.12);
letter-spacing: 0.04em;
}
/* ── Pending-updates badge ─────────────────────────────────────────────────── */
.cn-upd {
font-size: 0.62rem;
font-family: var(--font-tech);
padding: 1px 4px;
border-radius: 3px;
letter-spacing: 0.04em;
}
.cn-upd.upd-ok { color: #00ff88; background: rgba(0,255,136,0.08); }
.cn-upd.upd-warn { color: var(--neon-amber); background: rgba(255,176,32,0.1); }
.cn-upd.upd-bad { color: #ff4444; background: rgba(255,68,68,0.12); font-weight: 700; }
.cn-upd.upd-unk { color: #888; background: rgba(128,128,128,0.08); }
/* ── Reboot-pending badge ──────────────────────────────────────────────────── */
.cn-reboot {
font-size: 0.62rem;
font-family: var(--font-tech);
padding: 1px 4px;
border-radius: 3px;
letter-spacing: 0.04em;
}
.cn-reboot.rb-pending {
color: #ff2222;
background: rgba(255,34,34,0.15);
font-weight: 700;
animation: rb-blink 1.4s step-end infinite;
}
@keyframes rb-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.45; }
}
/* ── Row: Groups + Actions ───────────────────────────────────────────── */
.crucible-row {

View File

@@ -45,6 +45,57 @@ function sshBadge(agent: Agent) {
return { label: 'SSH ?', cls: 'ssh-unk' };
}
function postureBadge(score?: number) {
if (score === undefined) return { label: 'POSTURE ?', cls: 'posture-unk' };
if (score >= 80) return { label: `P:${score}`, cls: 'posture-good' };
if (score >= 40) return { label: `P:${score}`, cls: 'posture-warn' };
return { label: `P:${score}`, cls: 'posture-bad' };
}
function patchLabel(days?: number) {
if (days === undefined) return null;
return { label: `${days}d`, cls: days <= 30 ? 'patch-ok' : 'patch-stale' };
}
function postureTooltip(agent: Agent): string {
const lines: string[] = [];
const yn = (v?: boolean) => v === true ? '✓' : v === false ? '✗' : '?';
const na = (v: unknown) => v !== undefined && v !== null ? String(v) : '?';
lines.push(`Defender: ${yn(agent.defender_enabled)} RTP: ${yn(agent.defender_rtp)}`);
if (agent.av_products?.length) lines.push(`AV: ${agent.av_products.join(', ')}`);
lines.push(`FW Domain:${yn(agent.firewall_domain)} Private:${yn(agent.firewall_private)} Public:${yn(agent.firewall_public)}`);
lines.push(`SSH: ${yn(agent.ssh_available)} Elevated: ${yn(agent.agent_elevated)}`);
lines.push('──────────────────────');
// Patch exposure
if (agent.last_patch) lines.push(`Last patch: ${agent.last_patch} (${na(agent.last_patch_days)}d ago)`);
else if (agent.last_patch_days !== undefined) lines.push(`Last patch: ${agent.last_patch_days}d ago`);
if (agent.pending_updates !== undefined) {
const u = agent.pending_updates;
lines.push(`Pending updates: ${u < 0 ? 'unknown' : u === 0 ? 'none ✓' : `${u}`}`);
}
if (agent.reboot_pending !== undefined) {
lines.push(`Reboot required: ${agent.reboot_pending ? 'YES ⚠' : 'no ✓'}`);
}
return lines.join('\n');
}
function pendingBadge(agent: Agent): { label: string; cls: string } | null {
const u = agent.pending_updates;
if (u === undefined) return null;
if (u < 0) return { label: 'UPD ?', cls: 'upd-unk' };
if (u === 0) return { label: 'UP TO DATE', cls: 'upd-ok' };
if (u <= 5) return { label: `${u} UPD`, cls: 'upd-warn' };
return { label: `${u} UPD`, cls: 'upd-bad' };
}
function rebootBadge(agent: Agent): { label: string; cls: string } | null {
if (agent.reboot_pending === undefined) return null;
if (agent.reboot_pending) return { label: 'REBOOT!', cls: 'rb-pending' };
return null; // no badge when not pending — cleaner UI
}
function platformIcon(platform?: string): string {
if (!platform) return '⬡';
const p = platform.toLowerCase();
@@ -99,8 +150,9 @@ export default function CruciblePage() {
const [cmdHistory, setCmdHistory] = useState<string[]>([]);
const [histIdx, setHistIdx] = useState(-1);
// SSH status overrides (from probe results)
// SSH / posture overrides (from on-demand probes)
const [sshOverride, setSshOverride] = useState<Record<string, boolean>>({});
const [postureOverride, setPostureOverride] = useState<Record<string, { score: number; patchDays?: number }>>({});
const allIds = useMemo(() => agents.map((a) => a.id), [agents]);
const selectedAgents = useMemo(
@@ -128,20 +180,35 @@ export default function CruciblePage() {
for (const r of newEntries) {
const aid = r.agent_id;
if (!aid) continue;
// Only show results from agents that are selected (or all if nothing selected)
if (selectedIds.size > 0 && !selectedIds.has(aid)) continue;
const agent = agents.find((a) => a.id === aid);
const name = agent?.name ?? aid.slice(0, 8);
// Parse SSH probe results to update ssh status
const msg = r.message ?? '';
// Always update badges from probe / heartbeat command responses
if (msg.includes('SSH_PROBE:ONLINE')) {
setSshOverride((prev) => ({ ...prev, [aid]: true }));
} else if (msg.includes('SSH_PROBE:OFFLINE')) {
setSshOverride((prev) => ({ ...prev, [aid]: false }));
}
if (r.action === 'posture' || msg.includes('{')) {
try {
const start = msg.indexOf('{');
if (start >= 0) {
const p = JSON.parse(msg.slice(start)) as { posture_score?: number; last_patch_days?: number; ssh_listening?: boolean };
if (typeof p.posture_score === 'number') {
setPostureOverride((prev) => ({
...prev,
[aid]: { score: p.posture_score!, patchDays: p.last_patch_days },
}));
}
if (p.ssh_listening === true) setSshOverride((prev) => ({ ...prev, [aid]: true }));
if (p.ssh_listening === false) setSshOverride((prev) => ({ ...prev, [aid]: false }));
}
} catch { /* ignore malformed JSON */ }
}
if (selectedIds.size > 0 && !selectedIds.has(aid)) continue;
const agent = agents.find((a) => a.id === aid);
const name = agent?.name ?? aid.slice(0, 8);
// Split multi-line output
const msgLines = msg.split('\n').filter(Boolean);
for (const line of msgLines) {
lines.push({
@@ -258,6 +325,28 @@ export default function CruciblePage() {
}
};
const probePosture = (targets?: Agent[]) => {
const tgts = targets ?? selectedAgents.filter(online);
Promise.all(
tgts.map((a) =>
api.sendAgentCommand(a.id, 'posture').catch((err) => {
setTermLines((prev) => [
...prev,
{
id: mkId(),
agentId: a.id,
agentName: a.name,
isCmd: false,
text: `[ERROR] posture probe: ${err instanceof Error ? err.message : String(err)}`,
ts: new Date(),
success: false,
},
]);
})
)
);
};
const handleKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') { sendCmd(); return; }
if (e.key === 'ArrowUp') {
@@ -282,6 +371,13 @@ export default function CruciblePage() {
return sshBadge({ ...a });
};
const postureStatus = (a: Agent) => {
const o = postureOverride[a.id];
const score = o?.score ?? a.posture_score;
const patchDays = o?.patchDays ?? a.last_patch_days;
return { ...postureBadge(score), patch: patchLabel(patchDays) };
};
// ── Render ─────────────────────────────────────────────────────────────
return (
@@ -319,6 +415,7 @@ export default function CruciblePage() {
const sel = selectedIds.has(a.id);
const isOn = online(a);
const ssh = sshStatus(a);
const posture = postureStatus(a);
const color = agentColor(a.id, allIds);
return (
<div
@@ -345,7 +442,36 @@ export default function CruciblePage() {
<span>{a.cpu_cores}c</span>
<span>{formatHashrate(a.hashrate_15m)}</span>
</div>
<div className={`cn-ssh ${ssh.cls}`}>{ssh.label}</div>
<div className="cn-badges">
<div className={`cn-ssh ${ssh.cls}`}>{ssh.label}</div>
<div
className={`cn-posture ${posture.cls}`}
title={postureTooltip(a)}
>
{posture.label}
</div>
{posture.patch && (
<div
className={`cn-patch ${posture.patch.cls}`}
title={`Last patch: ${a.last_patch ?? '?'} (${a.last_patch_days ?? '?'}d ago)`}
>
{posture.patch.label}
</div>
)}
{(() => { const pb = pendingBadge(a); return pb && (
<div className={`cn-upd ${pb.cls}`} title={`${pb.label === 'UP TO DATE' ? 'No pending updates' : `${a.pending_updates} pending update(s)`}`}>
{pb.label}
</div>
); })()}
{(() => { const rb = rebootBadge(a); return rb && (
<div className={`cn-reboot ${rb.cls}`} title="System reboot required to apply updates">
{rb.label}
</div>
); })()}
{a.agent_elevated && (
<div className="cn-elevated" title="Running as Administrator / root">ADMIN</div>
)}
</div>
</div>
</div>
);
@@ -393,6 +519,26 @@ export default function CruciblePage() {
<span className="section-ornament"></span> OPERATIONS
</div>
<div className="crucible-ops">
<div className="crucible-op-group">
<span className="cop-label">Posture</span>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
onClick={() => probePosture()}
title="Probe selected: AV, RTP, firewall (per-profile), SSH, patch age, elevation"
>
Probe Selected
</button>
<button
className="button crucible-op-btn crucible-op-wake"
disabled={agents.filter(online).length === 0}
onClick={() => probePosture(agents.filter(online))}
title="Probe ALL online nodes at once"
>
Probe All
</button>
</div>
<div className="crucible-op-group">
<span className="cop-label">SSH</span>
<button

View File

@@ -0,0 +1,190 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import DashboardPage, { formatShareTime } from './DashboardPage';
import { mockAgent, mockShare } from '../test/fixtures';
import { useWebSocket } from '../hooks/useWebSocket';
import { api } from '../api/client';
vi.mock('../hooks/useWebSocket', () => ({
useWebSocket: vi.fn(),
}));
vi.mock('../components/Visual/3D/FleetTopologyMap', () => ({
default: () => <div data-testid="fleet-topology-map" />,
}));
vi.mock('../components/Visual/MatrixStreamOverlay', () => ({
default: () => null,
}));
const useWebSocketMock = vi.mocked(useWebSocket);
function wsValue(overrides: Partial<ReturnType<typeof useWebSocket>> = {}) {
return {
isConnected: true,
agents: [],
recentShares: [],
fleetAlerts: [],
poolStatus: [],
aiActivity: [],
agentLogs: {},
commandResults: [],
latestMessage: null,
...overrides,
};
}
function renderDashboard() {
return render(
<MemoryRouter>
<DashboardPage />
</MemoryRouter>
);
}
describe('formatShareTime', () => {
it('formats ISO timestamps as locale time strings', () => {
const iso = '2026-05-30T12:00:00.000Z';
expect(formatShareTime(iso)).toBe(new Date(iso).toLocaleTimeString());
});
});
describe('DashboardPage', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
useWebSocketMock.mockReturnValue(wsValue());
vi.spyOn(api, 'getRecentShares').mockResolvedValue([]);
vi.spyOn(api, 'listBuilds').mockResolvedValue([]);
vi.spyOn(api, 'getConfig').mockResolvedValue({
port: 8080,
data_dir: '',
pool: {} as never,
wallet: {} as never,
server: { dashboard_subtitle: 'custom subtitle from server' },
alerts: {} as never,
});
vi.spyOn(api, 'getAlerts').mockResolvedValue([]);
vi.spyOn(api, 'getPoolStatus').mockResolvedValue([]);
vi.spyOn(api, 'getAIActivity').mockResolvedValue([]);
vi.spyOn(api, 'getXmrPrice').mockResolvedValue({ usd: 165.5, updated_at: '' });
vi.spyOn(api, 'getEarningsEstimate').mockResolvedValue({
xmr_per_day: 0.01,
usd_per_day: 1.65,
network_hashrate: 1e9,
});
vi.spyOn(api, 'sendBulkCommand').mockResolvedValue({
success: true,
sent: 1,
failed: 0,
action: 'restart',
});
});
afterEach(() => {
cleanup();
});
it('renders hero heading and key section titles', () => {
renderDashboard();
expect(screen.getByRole('heading', { level: 1, name: 'Command Deck' })).toBeInTheDocument();
expect(screen.getByText('Fleet Pipeline')).toBeInTheDocument();
expect(screen.getByText('Share Activity Pulse')).toBeInTheDocument();
expect(screen.getByText('Machine Roster')).toBeInTheDocument();
expect(screen.getByText('PERSONAL NETWORK · LIVE TELEMETRY')).toBeInTheDocument();
});
it('shows reconnecting label when websocket is down', () => {
useWebSocketMock.mockReturnValue(wsValue({ isConnected: false }));
renderDashboard();
expect(screen.getByText('RECONNECTING')).toBeInTheDocument();
});
it('shows signal locked when websocket is connected', () => {
renderDashboard();
expect(screen.getByText('SIGNAL LOCKED')).toBeInTheDocument();
expect(screen.getByText('0 nodes registered')).toBeInTheDocument();
});
it('loads dashboard subtitle from config API', async () => {
renderDashboard();
expect(await screen.findByText('custom subtitle from server')).toBeInTheDocument();
expect(api.getConfig).toHaveBeenCalled();
});
it('shows empty roster state when no agents', async () => {
renderDashboard();
expect(await screen.findByText('No miners on the wire')).toBeInTheDocument();
});
it('renders stat labels and top agent card', async () => {
const agent = mockAgent({ name: 'Alpha Node', hashrate_15m: 1200 });
useWebSocketMock.mockReturnValue(wsValue({ agents: [agent] }));
renderDashboard();
expect(await screen.findByText('Total Hashrate')).toBeInTheDocument();
expect(screen.getByText('Fleet Online')).toBeInTheDocument();
expect(screen.getByText('Accept Rate')).toBeInTheDocument();
const roster = screen.getByText('Machine Roster').closest('section') as HTMLElement;
expect(within(roster).getByText('Alpha Node')).toBeInTheDocument();
expect(within(roster).getByText('online')).toBeInTheDocument();
});
it('toggles advanced mode and reveals share log section', async () => {
const user = userEvent.setup();
vi.spyOn(api, 'getRecentShares').mockResolvedValue([mockShare()]);
renderDashboard();
expect(screen.queryByText('Share Log')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: '[ADVANCED]' }));
expect(screen.getByText('Share Log')).toBeInTheDocument();
expect(localStorage.getItem('aether-dash-advanced')).toBe('1');
});
it('shows share log rows in advanced mode with stable keys', async () => {
const share = mockShare({ id: undefined as unknown as number, hash: 'deadbeef' });
vi.spyOn(api, 'getRecentShares').mockResolvedValue([share]);
localStorage.setItem('aether-dash-advanced', '1');
useWebSocketMock.mockReturnValue(wsValue({ agents: [mockAgent()] }));
renderDashboard();
expect(await screen.findByText('Share Log')).toBeInTheDocument();
expect(screen.getByText('Accepted')).toBeInTheDocument();
});
it('alerts when bulk command API fails', async () => {
const agent = mockAgent();
useWebSocketMock.mockReturnValue(wsValue({ agents: [agent] }));
vi.spyOn(api, 'sendBulkCommand').mockRejectedValue(new Error('network down'));
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
renderDashboard();
const roster = await screen.findByText('Machine Roster');
const section = roster.closest('section') as HTMLElement;
await waitFor(() => expect(within(section).getByText(agent.name)).toBeInTheDocument());
const user = userEvent.setup();
const grid = section.querySelector('.agent-grid') as HTMLElement;
await user.click(within(grid).getByRole('checkbox'));
const bulkBar = section.querySelector('.fleet-bulk-bar') as HTMLElement;
await user.click(within(bulkBar).getByRole('button', { name: 'Pause' }));
await waitFor(() => {
expect(alertSpy).toHaveBeenCalledWith('network down');
});
alertSpy.mockRestore();
});
it('prefers live websocket alerts over REST fallback', async () => {
useWebSocketMock.mockReturnValue(
wsValue({
fleetAlerts: [{ id: '1', level: 'warn', type: 'pool_down', message: 'live alert', timestamp: '' }],
})
);
vi.spyOn(api, 'getAlerts').mockResolvedValue([
{ id: '2', level: 'warn', type: 'stale', message: 'rest alert', timestamp: '' },
]);
renderDashboard();
expect(await screen.findByText('live alert')).toBeInTheDocument();
expect(screen.queryByText('rest alert')).not.toBeInTheDocument();
});
});

View File

@@ -514,8 +514,8 @@ export default function DashboardPage() {
<tr><td colSpan={4} className="empty-table">No shares yet awaiting proof of work...</td></tr>
)}
{shares.map((share) => (
<tr key={share.id}>
<td className="time-cell font-tech">{formatTime(share.timestamp)}</td>
<tr key={share.id ?? `${share.agent_id}-${share.hash}-${share.timestamp}`}>
<td className="time-cell font-tech">{formatShareTime(share.timestamp)}</td>
<td className="mono-sm">{share.agent_id?.substring(0, 8)}</td>
<td>
<span className={`status-badge ${share.accepted ? 'online' : 'error'}`}>
@@ -538,6 +538,6 @@ export default function DashboardPage() {
);
}
function formatTime(t: string): string {
export function formatShareTime(t: string): string {
return new Date(t).toLocaleTimeString();
}

View File

@@ -587,13 +587,12 @@ export default function SettingsPage() {
<NeonCard accent="magenta" className="settings-section">
<h2 className="font-display">Access Control</h2>
<p className="section-desc">
API routes require login. Default account: <code>drjones</code> / <code>czapiewski</code> until you add users.
Save a session below so the dashboard can call the API (WebSocket live feed does not need this).
API routes require login. On first server start, credentials are printed once in the server console (<code>admin</code> + random password). Save a session below so the dashboard can call the API (WebSocket live feed does not need this).
</p>
<div className="form-row">
<div className="form-group">
<label className="label">Browser session username</label>
<input type="text" className="input" placeholder="drjones" value={sessionUser}
<input type="text" className="input" placeholder="admin" value={sessionUser}
onChange={(e) => setSessionUser(e.target.value)} />
</div>
<div className="form-group">