feat: Tenable-style patch_status - pending_updates, last_patch, reboot_pending across full stack
This commit is contained in:
215
server/web/src/pages/AgentsPage.test.tsx
Normal file
215
server/web/src/pages/AgentsPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
190
server/web/src/pages/DashboardPage.test.tsx
Normal file
190
server/web/src/pages/DashboardPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user