Add phenotype cloning, failure atlas, AI court session, and clearance L0-L4
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled

This commit is contained in:
AetherForge
2026-06-07 02:41:54 -07:00
parent 4b94776432
commit d9f36f182c
47 changed files with 4859 additions and 94 deletions

View File

@@ -1,40 +1,16 @@
import { expect, test } from '@playwright/test';
import { fetchFleetSecret, loginToDashboard } from './fixtures';
import {
connectStubAgent,
E2E_STUB_AGENT_HOSTNAME,
E2E_STUB_AGENT_ID,
} from './stub-agent';
const baseURL = process.env.AETHERFORGE_URL || 'http://127.0.0.1:8989';
let serverReady = false;
let disconnectStub: (() => void) | null = null;
import { loginToDashboard } from './fixtures';
import { ensureLiveStubAgent, isLiveStubReady } from './live-stub';
import { E2E_STUB_AGENT_HOSTNAME, E2E_STUB_AGENT_ID } from './stub-agent';
test.describe('Crucible bulk command', () => {
test.beforeAll(async ({ request }) => {
try {
const res = await request.get('/api/v1/health', { timeout: 5_000 });
serverReady = res.ok();
} catch {
serverReady = false;
}
if (!serverReady) return;
const fleetSecret = await fetchFleetSecret(request);
disconnectStub = await connectStubAgent(baseURL, fleetSecret);
// Allow agent_online + DB upsert to settle before UI tests.
await new Promise((r) => setTimeout(r, 500));
});
test.afterAll(() => {
disconnectStub?.();
disconnectStub = null;
await ensureLiveStubAgent(request);
});
test.beforeEach(async ({ page }) => {
test.skip(
!serverReady,
!isLiveStubReady(),
'Requires live E2E server (test-suite phase 8 on :18989 or AETHERFORGE_URL)',
);
await loginToDashboard(page);
@@ -49,8 +25,12 @@ test.describe('Crucible bulk command', () => {
const card = page.locator('.crucible-node-card').filter({ hasText: E2E_STUB_AGENT_HOSTNAME });
await expect(card.locator('.cn-status-dot.on')).toBeVisible({ timeout: 15_000 });
await card.click();
await expect(page.getByText(/1 selected/)).toBeVisible();
await expect(page.getByText(new RegExp(`→ 1 node.*${E2E_STUB_AGENT_HOSTNAME}`))).toBeVisible();
await expect(page.locator('.fleet-bulk-bar').getByText(/1 selected/)).toBeVisible({
timeout: 10_000,
});
await expect(
page.locator('.crucible-actions-card').getByText(new RegExp(`${E2E_STUB_AGENT_HOSTNAME}`)),
).toBeVisible();
const bulkRequest = page.waitForRequest(
(req) =>

View File

@@ -1,41 +1,16 @@
import { expect, test } from '@playwright/test';
import { fetchFleetSecret, loginToDashboard } from './fixtures';
import {
connectStubAgent,
E2E_STUB_AGENT_HOSTNAME,
E2E_STUB_LOTL_BADGE,
E2E_WHOAMI_RESPONSE,
} from './stub-agent';
const baseURL = process.env.AETHERFORGE_URL || 'http://127.0.0.1:8989';
let serverReady = false;
let disconnectStub: (() => void) | null = null;
import { loginToDashboard } from './fixtures';
import { ensureLiveStubAgent, isLiveStubReady } from './live-stub';
import { E2E_STUB_AGENT_HOSTNAME, E2E_WHOAMI_RESPONSE } from './stub-agent';
test.describe('Crucible remote command', () => {
test.beforeAll(async ({ request }) => {
try {
const res = await request.get('/api/v1/health');
serverReady = res.ok();
} catch {
serverReady = false;
}
if (!serverReady) return;
const fleetSecret = await fetchFleetSecret(request);
disconnectStub = await connectStubAgent(baseURL, fleetSecret);
// Allow agent_online, stats_batch (250ms coalesce), and DB upsert to settle.
await new Promise((r) => setTimeout(r, 1_500));
});
test.afterAll(() => {
disconnectStub?.();
disconnectStub = null;
await ensureLiveStubAgent(request);
});
test.beforeEach(async ({ page }) => {
test.skip(
!serverReady,
!isLiveStubReady(),
'Requires live E2E server (test-suite phase 8 on :18989 or AETHERFORGE_URL)',
);
await loginToDashboard(page);
@@ -46,20 +21,20 @@ test.describe('Crucible remote command', () => {
test('whoami on selected online node shows terminal output', async ({ page }) => {
await page.getByText(E2E_STUB_AGENT_HOSTNAME).click();
await expect(page.getByText(new RegExp(`→ 1 node.*${E2E_STUB_AGENT_HOSTNAME}`))).toBeVisible();
await expect(
page.locator('.crucible-actions-card').getByText(new RegExp(`${E2E_STUB_AGENT_HOSTNAME}`)),
).toBeVisible();
await page.getByRole('button', { name: 'CMD', exact: true }).click();
await page.getByRole('button', { name: 'whoami' }).click();
const input = page.locator('.crucible-term-input');
await input.fill('whoami');
await page.getByRole('button', { name: 'SEND' }).click();
const terminal = page.locator('.crucible-terminal');
await expect(terminal.getByText('whoami', { exact: true })).toBeVisible({ timeout: 10_000 });
await expect(terminal.getByText(E2E_WHOAMI_RESPONSE)).toBeVisible({ timeout: 15_000 });
});
test('shows LOTL tier badge when stub sends lotl_tier', async ({ page }) => {
const card = page.locator('.crucible-node-card').filter({ hasText: E2E_STUB_AGENT_HOSTNAME });
await expect(card.getByText(E2E_STUB_LOTL_BADGE)).toBeVisible({ timeout: 15_000 });
});
test('exec echo via master terminal shows output', async ({ page }) => {
await page.getByText(E2E_STUB_AGENT_HOSTNAME).click();
await page.getByRole('button', { name: 'CMD', exact: true }).click();

View File

@@ -0,0 +1,42 @@
import { expect, test } from '@playwright/test';
import { loginToDashboard } from './fixtures';
import { ensureLiveStubAgent, isLiveStubReady } from './live-stub';
import { E2E_STUB_AGENT_HOSTNAME, E2E_STUB_LOTL_BADGE } from './stub-agent';
test.describe('Crucible LOTL', () => {
test.beforeAll(async ({ request }) => {
await ensureLiveStubAgent(request);
});
test.beforeEach(async ({ page }) => {
test.skip(
!isLiveStubReady(),
'Requires live E2E server (test-suite phase 8 on :18989 or AETHERFORGE_URL)',
);
await loginToDashboard(page);
});
test('Crucible node card shows LOTL tier badge from stub stats', async ({ page }) => {
await page.getByRole('link', { name: /Crucible/i }).click();
await expect(page.getByRole('heading', { name: 'Crucible' })).toBeVisible({ timeout: 10_000 });
const card = page.locator('.crucible-node-card').filter({ hasText: E2E_STUB_AGENT_HOSTNAME });
await expect(card).toBeVisible({ timeout: 15_000 });
await expect(card.getByText(E2E_STUB_LOTL_BADGE)).toBeVisible({ timeout: 15_000 });
});
test('Onion timeline shows stub agent tier progression', async ({ page }) => {
await page.getByRole('navigation').getByRole('link', { name: 'Onion', exact: true }).click();
await expect(page.getByRole('heading', { name: 'LOTL Timeline' })).toBeVisible({
timeout: 10_000,
});
await expect(
page.locator('.lotl-fleet-chip').filter({ hasText: E2E_STUB_AGENT_HOSTNAME }),
).toBeVisible({ timeout: 15_000 });
await expect(page.getByText('ONION TIER CHAIN')).toBeVisible();
await expect(page.locator('.lotl-tier-timeline')).toBeVisible();
// Stub lotl_attempts: container (aliases to docker) failed — spread onion timeline.
await expect(page.locator('.lotl-tier-step--failed', { hasText: /Docker/i })).toBeVisible({
timeout: 15_000,
});
});
});

View File

@@ -15,14 +15,52 @@ export function e2eAuthHeaders(): Record<string, string> {
};
}
/** Reads fleet_secret from live server config (generated on first server start). */
/**
* Reads fleet_secret for stub agent auth.
* Prefer AETHERFORGE_FLEET_SECRET (test-suite.ps1 seeds from data/config.json).
*/
export async function fetchFleetSecret(request: APIRequestContext): Promise<string> {
const res = await request.get('/api/v1/config', { headers: e2eAuthHeaders() });
const fromEnv = process.env.AETHERFORGE_FLEET_SECRET?.trim();
if (fromEnv) return fromEnv;
const res = await request.get('/api/v1/config', {
headers: e2eAuthHeaders(),
timeout: 10_000,
});
if (!res.ok()) {
throw new Error(`config fetch failed: ${res.status()}`);
}
const body = (await res.json()) as { server?: { fleet_secret?: string } };
return body.server?.fleet_secret ?? '';
const secret = body.server?.fleet_secret?.trim() ?? '';
if (!secret) {
throw new Error(
'fleet_secret missing — set AETHERFORGE_FLEET_SECRET from data/config.json in the E2E runner',
);
}
return secret;
}
/** Poll /api/v1/health until status ok or timeout (mirrors test-suite.ps1 phase 8). */
export async function waitForServerHealth(
request: APIRequestContext,
opts?: { timeoutMs?: number; intervalMs?: number },
): Promise<boolean> {
const timeoutMs = opts?.timeoutMs ?? 30_000;
const intervalMs = opts?.intervalMs ?? 1_000;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const res = await request.get('/api/v1/health', { timeout: 2_000 });
if (res.ok()) {
const body = (await res.json()) as { status?: string };
if (body.status === 'ok') return true;
}
} catch {
/* retry */
}
await new Promise((r) => setTimeout(r, intervalMs));
}
return false;
}
export async function loginToDashboard(page: Page): Promise<void> {
@@ -31,5 +69,5 @@ export async function loginToDashboard(page: Page): Promise<void> {
await page.getByLabel('Username').fill(E2E_USER);
await page.getByLabel('Password').fill(E2E_PASS);
await page.getByRole('button', { name: /enter command deck/i }).click();
await expect(page.getByRole('heading', { name: 'Command Deck' })).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole('heading', { name: 'Command Deck' })).toBeVisible({ timeout: 20_000 });
}

View File

@@ -0,0 +1,5 @@
import { teardownLiveStubAgent } from './live-stub';
export default function globalTeardown(): void {
teardownLiveStubAgent();
}

View File

@@ -0,0 +1,38 @@
import type { APIRequestContext } from '@playwright/test';
import { fetchFleetSecret, waitForServerHealth } from './fixtures';
import { connectStubAgent } from './stub-agent';
const baseURL = process.env.AETHERFORGE_URL || 'http://127.0.0.1:8989';
let serverReady = false;
let disconnectStub: (() => void) | null = null;
let connectPromise: Promise<boolean> | null = null;
/** One stub agent per Playwright worker (avoids parallel auth races on the same agent_id). */
export async function ensureLiveStubAgent(request: APIRequestContext): Promise<boolean> {
if (disconnectStub) return serverReady;
if (!connectPromise) {
connectPromise = (async () => {
serverReady = await waitForServerHealth(request);
if (!serverReady) return false;
const fleetSecret = await fetchFleetSecret(request);
disconnectStub = await connectStubAgent(baseURL, fleetSecret);
// Allow agent_online, stats_batch (250ms coalesce), and DB upsert to settle.
await new Promise((r) => setTimeout(r, 2_500));
return true;
})();
}
return connectPromise;
}
export function isLiveStubReady(): boolean {
return serverReady;
}
export function teardownLiveStubAgent(): void {
disconnectStub?.();
disconnectStub = null;
connectPromise = null;
serverReady = false;
}

View File

@@ -29,6 +29,19 @@ test.describe('Page smoke', () => {
await expect(page.getByRole('button', { name: 'Save Calibration' })).toBeVisible();
});
test('Settings shows Calibration Control mode toggle', async ({ page }) => {
await page.getByRole('navigation').getByRole('link', { name: 'Calibrate', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Calibrate' })).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole('group', { name: 'Calibration control mode' })).toBeVisible({
timeout: 10_000,
});
await expect(page.getByRole('button', { name: /Logic gates/i })).toBeVisible();
await expect(page.getByRole('button', { name: /AI Control/i })).toBeVisible();
await page.getByRole('button', { name: /AI Control/i }).click();
await expect(page.getByPlaceholderText('http://127.0.0.1:11434/v1')).toBeVisible();
await expect(page.getByRole('button', { name: 'Refresh models' })).toBeVisible();
});
test('Builder renders The Forge', async ({ page }) => {
await page.getByRole('link', { name: /Forge/i }).click();
await expect(page.getByRole('heading', { name: 'The Forge' })).toBeVisible({ timeout: 10_000 });

View File

@@ -99,18 +99,25 @@ export async function connectStubAgent(
});
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('stub agent auth timeout')), 10_000);
ws.addEventListener('message', (ev) => {
const msg = JSON.parse(String(ev.data)) as HubMessage;
const timer = setTimeout(() => reject(new Error('stub agent auth timeout')), 30_000);
const onMessage = (ev: MessageEvent) => {
let msg: HubMessage;
try {
msg = JSON.parse(String(ev.data)) as HubMessage;
} catch {
return;
}
if (msg.type !== 'auth_response') return;
clearTimeout(timer);
ws.removeEventListener('message', onMessage);
const body = parsePayload(msg.payload);
if (body.success !== true) {
reject(new Error(`stub agent auth rejected: ${JSON.stringify(body)}`));
return;
}
resolve();
}, { once: true });
};
ws.addEventListener('message', onMessage);
});
sendStubStats(ws);

View File

@@ -1,9 +1,12 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
globalTeardown: './e2e/global-teardown.ts',
testDir: './e2e',
timeout: 60_000,
retries: 0,
// Live-server specs share one stub agent_id — parallel workers race on WS auth.
workers: process.env.AETHERFORGE_URL ? 1 : undefined,
use: {
baseURL: process.env.AETHERFORGE_URL || 'http://127.0.0.1:8989',
trace: 'on-first-retry',

View File

@@ -0,0 +1,57 @@
{
"name": "AetherForge",
"short_name": "AetherForge",
"description": "Fleet command & control — mine, manage and monitor your nodes from anywhere.",
"start_url": "/dashboard",
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#080604",
"theme_color": "#c9a227",
"categories": ["utilities", "productivity"],
"icons": [
{
"src": "/af-logo.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/af-logo.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"screenshots": [],
"shortcuts": [
{
"name": "Command Deck",
"short_name": "Deck",
"description": "Open fleet dashboard",
"url": "/dashboard",
"icons": [{ "src": "/af-logo.png", "sizes": "192x192" }]
},
{
"name": "Crucible",
"short_name": "Ops",
"description": "Remote operations theater",
"url": "/crucible",
"icons": [{ "src": "/af-logo.png", "sizes": "192x192" }]
},
{
"name": "ROI Intelligence",
"short_name": "ROI",
"description": "Earnings & profitability",
"url": "/roi",
"icons": [{ "src": "/af-logo.png", "sizes": "192x192" }]
},
{
"name": "Activity Feed",
"short_name": "Feed",
"description": "Live event stream",
"url": "/activity",
"icons": [{ "src": "/af-logo.png", "sizes": "192x192" }]
}
]
}

View File

@@ -22,6 +22,8 @@ const SettingsPage = lazy(() => import('./pages/SettingsPage'));
const PathTracerPage = lazy(() => import('./pages/PathTracerPage'));
const EmberwakePage = lazy(() => import('./pages/EmberwakePage'));
const LotlTimelinePage = lazy(() => import('./pages/LotlTimelinePage'));
const ROIPage = lazy(() => import('./pages/ROIPage'));
const ActivityFeedPage = lazy(() => import('./pages/ActivityFeedPage'));
export function PageFallback() {
return (
@@ -62,6 +64,8 @@ function App() {
<Route path="/pathtracer" element={<PathTracerPage />} />
<Route path="/lotl-timeline" element={<LotlTimelinePage />} />
<Route path="/onion" element={<Navigate to="/lotl-timeline" replace />} />
<Route path="/roi" element={<ROIPage />} />
<Route path="/activity" element={<ActivityFeedPage />} />
</Routes>
</Suspense>
</Layout>

View File

@@ -10,7 +10,9 @@ import {
layoutAgentPoints,
layoutComradePoints,
} from '../../help/fleetHeatMap';
import NetworkTopoMap from './NetworkTopoMap';
import './FleetHeatMiniMap.css';
import './NetworkTopoMap.css';
interface FleetHeatMiniMapProps {
agents: Agent[];
@@ -30,6 +32,7 @@ export default function FleetHeatMiniMap({
const { comrades } = usePresence();
const prevHashrateRef = useRef<Record<string, number>>({});
const [spikingIds, setSpikingIds] = useState<Set<string>>(() => new Set());
const [view, setView] = useState<'heat' | 'topo'>('heat');
useEffect(() => {
const spikes = new Set<string>();
@@ -58,14 +61,46 @@ export default function FleetHeatMiniMap({
<div className="fleet-heat-minimap">
<div className="fleet-heat-header font-tech">
<span className="section-ornament"></span>
FLEET HEAT
{view === 'heat' ? 'FLEET HEAT' : 'NETWORK MAP'}
<span className="fleet-heat-count">
{onlineCount}/{agents.length}
</span>
{/* HEAT / TOPO view toggle */}
<span style={{ marginLeft: 'auto', display: 'flex', gap: '0.2rem' }}>
{(['heat', 'topo'] as const).map((v) => (
<button
key={v}
type="button"
onClick={() => setView(v)}
style={{
padding: '0.1rem 0.4rem',
fontSize: '0.55rem',
fontFamily: 'inherit',
letterSpacing: '0.06em',
background: view === v ? 'rgba(0,232,245,0.15)' : 'none',
border: `1px solid ${view === v ? 'rgba(0,232,245,0.5)' : 'rgba(0,232,245,0.18)'}`,
borderRadius: '3px',
color: view === v ? 'var(--neon-cyan)' : 'var(--text-muted)',
cursor: 'pointer',
transition: 'all 0.15s',
}}
>
{v.toUpperCase()}
</button>
))}
</span>
</div>
{agents.length === 0 ? (
<p className="fleet-heat-empty">No nodes yet deploy a build to see the map.</p>
) : view === 'topo' ? (
<NetworkTopoMap
agents={agents}
groups={groups}
allIds={allIds}
selectedIds={selectedIds}
onSelectAgent={onSelectAgent}
/>
) : (
<div
className="fleet-heat-canvas"

View File

@@ -0,0 +1,286 @@
/* ── Network Topology Map ────────────────────────────────────────────────── */
/* Container */
.net-topo-wrap {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
}
/* SVG canvas */
.net-topo-svg {
width: 100%;
aspect-ratio: 1;
min-height: 200px;
border-radius: 8px;
border: 1px solid rgba(0, 232, 245, 0.18);
background:
radial-gradient(ellipse at 50% 45%, rgba(0, 232, 245, 0.06) 0%, transparent 70%),
rgba(0, 0, 0, 0.5);
display: block;
overflow: visible;
}
/* ── Subnet bubbles ──────────────────────────────────────────────────────── */
.topo-subnet-bubble {
fill: transparent;
stroke-width: 0.6;
opacity: 0.85;
}
.topo-subnet-label {
font-size: 2.2px;
font-family: 'Share Tech Mono', 'Courier New', monospace;
fill-opacity: 0.6;
letter-spacing: 0.15px;
pointer-events: none;
user-select: none;
}
/* ── Edges ───────────────────────────────────────────────────────────────── */
.topo-edge {
stroke-linecap: round;
pointer-events: none;
transition: opacity 0.2s, stroke-width 0.2s;
}
.topo-edge--subnet {
stroke-opacity: 0.18;
stroke-width: 0.3;
}
.topo-edge--subnet.active {
stroke-opacity: 0.55;
stroke-width: 0.45;
}
/* SMB / WinRM / spread edges — animated dash */
.topo-edge--spread,
.topo-edge--smb,
.topo-edge--winrm,
.topo-edge--ssh,
.topo-edge--cross_subnet {
stroke-width: 0.4;
stroke-opacity: 0.15;
stroke-dasharray: 1.2 1.8;
animation: topo-dash 2.5s linear infinite;
}
.topo-edge--spread.active,
.topo-edge--smb.active,
.topo-edge--winrm.active,
.topo-edge--ssh.active,
.topo-edge--cross_subnet.active {
stroke-opacity: 0.85;
stroke-width: 0.65;
animation-duration: 1.2s;
}
@keyframes topo-dash {
to { stroke-dashoffset: -6; }
}
/* Edge colors */
.topo-edge--smb { stroke: #ffb020; }
.topo-edge--winrm { stroke: #b24bf3; }
.topo-edge--ssh { stroke: #39ff14; }
.topo-edge--spread { stroke: #00e8f5; }
.topo-edge--cross_subnet { stroke: #ff6b35; stroke-dasharray: 0.8 2.2; }
/* ── Nodes ───────────────────────────────────────────────────────────────── */
.topo-node-circle {
transition: r 0.15s, filter 0.15s;
cursor: pointer;
}
.topo-node-circle--online {
filter: drop-shadow(0 0 1.2px var(--node-color, #00e8f5));
}
.topo-node-circle--offline {
opacity: 0.28;
filter: none;
}
.topo-node-circle--selected {
r: 3.2;
filter:
drop-shadow(0 0 2px #fff)
drop-shadow(0 0 4px var(--node-color, #00e8f5));
animation: topo-node-pulse 1.4s ease-in-out infinite;
}
@keyframes topo-node-pulse {
0%, 100% { filter: drop-shadow(0 0 2px #fff) drop-shadow(0 0 4px var(--node-color)); }
50% { filter: drop-shadow(0 0 3.5px #fff) drop-shadow(0 0 7px var(--node-color)); }
}
.topo-node-ring {
fill: none;
stroke-width: 0.5;
opacity: 0.6;
pointer-events: none;
}
.topo-node-icon {
font-size: 2.8px;
text-anchor: middle;
dominant-baseline: central;
pointer-events: none;
user-select: none;
}
/* Hashrate spike flash */
.topo-node-spike {
animation: topo-spike 1.1s ease-out forwards;
}
@keyframes topo-spike {
0% { r: 2.2; opacity: 1; }
40% { r: 5.5; opacity: 0.7; }
100% { r: 2.2; opacity: 0; }
}
/* ── Tooltip ─────────────────────────────────────────────────────────────── */
.topo-tooltip {
position: fixed;
z-index: 9999;
pointer-events: none;
background: rgba(5, 8, 15, 0.96);
border: 1px solid rgba(0, 232, 245, 0.35);
border-radius: 8px;
padding: 0.55rem 0.75rem;
font-family: 'Share Tech Mono', 'Courier New', monospace;
font-size: 0.72rem;
color: #c8d8e8;
min-width: 160px;
box-shadow:
0 0 0 1px rgba(0, 232, 245, 0.08),
0 6px 24px rgba(0, 0, 0, 0.7),
0 0 18px rgba(0, 232, 245, 0.12);
backdrop-filter: blur(8px);
}
.topo-tooltip-name {
font-size: 0.78rem;
font-weight: 700;
color: var(--neon-cyan, #00e8f5);
margin-bottom: 0.3rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.topo-tooltip-row {
display: flex;
justify-content: space-between;
gap: 0.75rem;
color: var(--text-muted, #8899aa);
font-size: 0.66rem;
line-height: 1.5;
}
.topo-tooltip-row span:last-child {
color: #c8d8e8;
text-align: right;
}
.topo-tooltip-lane {
display: inline-block;
margin-top: 0.3rem;
padding: 0.1rem 0.4rem;
border-radius: 4px;
font-size: 0.62rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.topo-tooltip-lane--smb { background: rgba(255,176,32,0.18); color: #ffb020; border: 1px solid rgba(255,176,32,0.3); }
.topo-tooltip-lane--winrm { background: rgba(178,75,243,0.18); color: #b24bf3; border: 1px solid rgba(178,75,243,0.3); }
.topo-tooltip-lane--ssh { background: rgba(57,255,20,0.12); color: #39ff14; border: 1px solid rgba(57,255,20,0.25); }
.topo-tooltip-lane--spread { background: rgba(0,232,245,0.12); color: #00e8f5; border: 1px solid rgba(0,232,245,0.25); }
/* ── Legend ──────────────────────────────────────────────────────────────── */
.topo-legend {
display: flex;
flex-wrap: wrap;
gap: 0.35rem 0.6rem;
font-family: 'Share Tech Mono', 'Courier New', monospace;
font-size: 0.6rem;
color: var(--text-muted, #8899aa);
letter-spacing: 0.04em;
}
.topo-legend-item {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.topo-legend-line {
display: inline-block;
width: 14px;
height: 2px;
border-radius: 1px;
flex-shrink: 0;
}
.topo-legend-line--subnet { background: rgba(200,216,232,0.35); }
.topo-legend-line--smb { background: #ffb020; }
.topo-legend-line--winrm { background: #b24bf3; }
.topo-legend-line--ssh { background: #39ff14; }
.topo-legend-line--cross_subnet{ background: #ff6b35; }
/* ── Mode tabs ───────────────────────────────────────────────────────────── */
.topo-mode-tabs {
display: flex;
gap: 0.25rem;
}
.topo-mode-tab {
padding: 0.18rem 0.5rem;
font-size: 0.6rem;
font-family: 'Share Tech Mono', 'Courier New', monospace;
letter-spacing: 0.08em;
background: none;
border: 1px solid rgba(0, 232, 245, 0.18);
border-radius: 4px;
color: var(--text-muted, #8899aa);
cursor: pointer;
transition: all 0.15s;
}
.topo-mode-tab:hover {
border-color: rgba(0, 232, 245, 0.5);
color: #c8d8e8;
}
.topo-mode-tab.active {
background: rgba(0, 232, 245, 0.12);
border-color: rgba(0, 232, 245, 0.5);
color: var(--neon-cyan, #00e8f5);
}
/* ── Empty state ─────────────────────────────────────────────────────────── */
.topo-empty {
margin: 0;
font-size: 0.72rem;
color: var(--text-muted, #8899aa);
}
/* ── Segmentation wall ───────────────────────────────────────────────────── */
.topo-seg-wall {
stroke: rgba(255, 100, 50, 0.12);
stroke-width: 0.2;
stroke-dasharray: 0.5 1;
pointer-events: none;
}

View File

@@ -0,0 +1,518 @@
import { useMemo, useState, useRef, useCallback, useEffect } from 'react';
import type { Agent } from '../../types';
import type { FleetGroup } from '../../help/fleetGroups';
import { formatHashrate } from '../../help/fleetFilters';
import {
groupBySubnet,
layoutSubnets,
layoutNodes,
buildEdges,
type TopoNode,
type TopoEdge,
type SubnetLayout,
} from '../../help/networkTopology';
import { agentAccentColor } from '../../help/fleetHeatMap';
import './NetworkTopoMap.css';
// ── Types ──────────────────────────────────────────────────────────────────
export type TopoMode = 'subnet' | 'spread' | 'flat';
interface Props {
agents: Agent[];
groups: FleetGroup[];
allIds: string[];
selectedIds: Set<string>;
onSelectAgent: (id: string) => void;
mode?: TopoMode;
}
// ── Platform icon helper ───────────────────────────────────────────────────
function platformIcon(platform: string): string {
const p = platform.toLowerCase();
if (p.includes('win')) return '⊞';
if (p.includes('linux')) return '🐧';
if (p.includes('darwin')) return '';
return '⬡';
}
// ── Hashrate spike tracking ────────────────────────────────────────────────
const SPIKE_RATIO = 1.3;
const SPIKE_MIN = 50;
function detectSpikes(
agents: Agent[],
prev: Record<string, number>,
): { spikes: Set<string>; next: Record<string, number> } {
const spikes = new Set<string>();
const next: Record<string, number> = {};
for (const a of agents) {
const cur = a.hashrate_15s ?? 0;
const p = prev[a.id];
if (cur > 0 && (p === undefined || p <= 0 ? cur >= SPIKE_MIN : cur - p >= SPIKE_MIN && cur >= p * SPIKE_RATIO)) {
spikes.add(a.id);
}
next[a.id] = cur;
}
return { spikes, next };
}
// ── Tooltip component ──────────────────────────────────────────────────────
interface TooltipState {
node: TopoNode;
x: number;
y: number;
}
function NodeTooltip({ tip }: { tip: TooltipState }) {
const { node, x, y } = tip;
const laneClass = node.joinLane ? `topo-tooltip-lane--${node.joinLane}` : 'topo-tooltip-lane--spread';
// Clamp tooltip to viewport
const tipW = 175;
const tipH = 130;
const left = Math.min(x + 12, window.innerWidth - tipW - 8);
const top = Math.min(y + 12, window.innerHeight - tipH - 8);
return (
<div
className="topo-tooltip"
style={{ left, top }}
>
<div className="topo-tooltip-name">
{platformIcon(node.platform)} {node.label}
</div>
<div className="topo-tooltip-row">
<span>IP</span><span>{node.ip}</span>
</div>
<div className="topo-tooltip-row">
<span>Subnet</span><span>{node.subnet === 'unknown' ? '—' : node.subnet.replace('.0/24', '.x')}</span>
</div>
<div className="topo-tooltip-row">
<span>Status</span>
<span style={{ color: node.online ? '#39ff14' : '#ff4444' }}>
{node.online ? 'ONLINE' : 'OFFLINE'}
</span>
</div>
{node.hashrate > 0 && (
<div className="topo-tooltip-row">
<span>Hashrate</span><span>{formatHashrate(node.hashrate)}</span>
</div>
)}
{node.latencyMs !== undefined && node.online && (
<div className="topo-tooltip-row">
<span>Latency</span><span>{node.latencyMs}ms</span>
</div>
)}
{node.joinLane && (
<div>
<span className={`topo-tooltip-lane ${laneClass}`}>
{node.joinLane.toUpperCase()} lane
</span>
</div>
)}
{node.canSpread && (
<div>
<span className="topo-tooltip-lane topo-tooltip-lane--spread">
SPREAD CAPABLE
</span>
</div>
)}
</div>
);
}
// ── Subnet bubble ──────────────────────────────────────────────────────────
function SubnetBubble({ layout }: { layout: SubnetLayout }) {
return (
<>
{/* Outer glow ring */}
<circle
cx={layout.cx}
cy={layout.cy}
r={layout.r + 1.5}
fill="none"
stroke={layout.color}
strokeWidth={0.3}
strokeOpacity={0.12}
/>
{/* Main bubble */}
<circle
className="topo-subnet-bubble"
cx={layout.cx}
cy={layout.cy}
r={layout.r}
stroke={layout.color}
strokeDasharray="2 1.5"
/>
{/* Fill */}
<circle
cx={layout.cx}
cy={layout.cy}
r={layout.r}
fill={layout.color}
fillOpacity={0.03}
pointerEvents="none"
/>
{/* Label */}
<text
className="topo-subnet-label"
x={layout.cx}
y={layout.cy - layout.r + 2.8}
textAnchor="middle"
fill={layout.color}
>
{layout.label}
</text>
</>
);
}
// ── Edge ───────────────────────────────────────────────────────────────────
function TopoEdgeEl({
edge,
nodeMap,
mode,
}: {
edge: TopoEdge;
nodeMap: Map<string, TopoNode>;
mode: TopoMode;
}) {
const src = nodeMap.get(edge.sourceId);
const tgt = nodeMap.get(edge.targetId);
if (!src || !tgt) return null;
// In spread mode, only show spread/protocol edges
if (mode === 'spread' && edge.kind === 'subnet') return null;
// In subnet mode, still show spread edges but dimmer unless active
const opacity = mode === 'flat' ? 0.1 : undefined;
const colorMap: Record<string, string> = {
subnet: 'rgba(200,216,232,0.25)',
spread: '#00e8f5',
smb: '#ffb020',
winrm: '#b24bf3',
ssh: '#39ff14',
cross_subnet: '#ff6b35',
};
return (
<line
className={`topo-edge topo-edge--${edge.kind} ${edge.active ? 'active' : ''}`}
x1={src.x}
y1={src.y}
x2={tgt.x}
y2={tgt.y}
stroke={colorMap[edge.kind] ?? '#00e8f5'}
style={opacity !== undefined ? { opacity } : undefined}
/>
);
}
// ── Node ───────────────────────────────────────────────────────────────────
function TopoNodeEl({
node,
selected,
spiking,
color,
onHover,
onLeave,
onClick,
}: {
node: TopoNode;
selected: boolean;
spiking: boolean;
color: string;
onHover: (node: TopoNode, e: React.MouseEvent) => void;
onLeave: () => void;
onClick: (id: string) => void;
}) {
const r = selected ? 3.0 : 2.1;
const statusCls = node.online
? selected ? 'topo-node-circle--online topo-node-circle--selected' : 'topo-node-circle--online'
: 'topo-node-circle--offline';
return (
<g
style={{ cursor: 'pointer' }}
onClick={() => onClick(node.id)}
onMouseEnter={(e) => onHover(node, e)}
onMouseLeave={onLeave}
>
{/* Spike flash ring */}
{spiking && (
<circle
cx={node.x}
cy={node.y}
r={r}
fill={color}
fillOpacity={0.5}
className="topo-node-spike"
style={{ '--node-color': color } as React.CSSProperties}
/>
)}
{/* Selection ring */}
{selected && (
<circle
cx={node.x}
cy={node.y}
r={r + 1.6}
className="topo-node-ring"
stroke={color}
/>
)}
{/* Spread-capable indicator ring */}
{node.canSpread && !selected && node.online && (
<circle
cx={node.x}
cy={node.y}
r={r + 0.8}
fill="none"
stroke={color}
strokeWidth={0.25}
strokeOpacity={0.4}
strokeDasharray="0.6 0.6"
/>
)}
{/* Main dot */}
<circle
cx={node.x}
cy={node.y}
r={r}
fill={color}
className={`topo-node-circle ${statusCls}`}
style={{ '--node-color': color } as React.CSSProperties}
/>
{/* Platform icon — only rendered at reasonable sizes */}
<text
x={node.x}
y={node.y}
className="topo-node-icon"
fillOpacity={node.online ? 0.9 : 0.4}
style={{ fontSize: selected ? '3.2px' : '2.6px', fill: '#000' }}
>
{node.platform.includes('win') ? '⊞' : node.platform.includes('linux') ? '⬡' : node.platform.includes('darwin') ? '◉' : '·'}
</text>
</g>
);
}
// ── Grid / background ──────────────────────────────────────────────────────
function TopoGrid() {
return (
<g pointerEvents="none">
{/* Horizontal lines */}
{[20, 40, 60, 80].map((y) => (
<line key={`h${y}`} x1={0} y1={y} x2={100} y2={y}
stroke="rgba(0,232,245,0.04)" strokeWidth={0.3} />
))}
{/* Vertical lines */}
{[20, 40, 60, 80].map((x) => (
<line key={`v${x}`} x1={x} y1={0} x2={x} y2={100}
stroke="rgba(0,232,245,0.04)" strokeWidth={0.3} />
))}
</g>
);
}
// ── Legend ─────────────────────────────────────────────────────────────────
function TopoLegend({ hasSpread }: { hasSpread: boolean }) {
return (
<div className="topo-legend">
<span className="topo-legend-item">
<span className="topo-legend-line topo-legend-line--subnet" />
Reachable
</span>
{hasSpread && (
<>
<span className="topo-legend-item">
<span className="topo-legend-line topo-legend-line--smb" />
SMB
</span>
<span className="topo-legend-item">
<span className="topo-legend-line topo-legend-line--winrm" />
WinRM
</span>
<span className="topo-legend-item">
<span className="topo-legend-line topo-legend-line--ssh" />
SSH
</span>
<span className="topo-legend-item">
<span className="topo-legend-line topo-legend-line--cross_subnet" />
Cross-subnet
</span>
</>
)}
</div>
);
}
// ── Main component ─────────────────────────────────────────────────────────
export default function NetworkTopoMap({
agents,
groups: _groups,
allIds,
selectedIds,
onSelectAgent,
mode: externalMode,
}: Props) {
const [mode, setMode] = useState<TopoMode>(externalMode ?? 'subnet');
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
const [spikingIds, setSpikingIds] = useState<Set<string>>(new Set());
const prevHashRef = useRef<Record<string, number>>({});
const svgRef = useRef<SVGSVGElement>(null);
// Spike detection
useEffect(() => {
const { spikes, next } = detectSpikes(agents, prevHashRef.current);
prevHashRef.current = next;
if (spikes.size === 0) return;
setSpikingIds(spikes);
const t = setTimeout(() => setSpikingIds(new Set()), 1300);
return () => clearTimeout(t);
}, [agents]);
// Layout
const subnetGroups = useMemo(() => groupBySubnet(agents), [agents]);
const subnets = useMemo(() => [...subnetGroups.keys()], [subnetGroups]);
const subnetLayouts = useMemo(() => layoutSubnets(subnets), [subnets]);
const nodes = useMemo(() => layoutNodes(agents, subnetLayouts), [agents, subnetLayouts]);
const edges = useMemo(() => buildEdges(agents, selectedIds), [agents, selectedIds]);
const nodeMap = useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]);
// Stats for legend
const hasSpread = edges.some((e) => e.kind !== 'subnet');
const onlineCount = agents.filter((a) => a.status === 'online').length;
const handleHover = useCallback((node: TopoNode, e: React.MouseEvent) => {
setTooltip({ node, x: e.clientX, y: e.clientY });
}, []);
const handleMove = useCallback((e: React.MouseEvent) => {
setTooltip((prev) => prev ? { ...prev, x: e.clientX, y: e.clientY } : null);
}, []);
const handleLeave = useCallback(() => setTooltip(null), []);
// In flat mode — use same positions as heat map (hashPosition fallback)
// In subnet/spread mode — use subnet-ring layout
if (agents.length === 0) {
return (
<div className="net-topo-wrap">
<p className="topo-empty">No nodes deploy a build to see topology.</p>
</div>
);
}
return (
<div className="net-topo-wrap">
{/* Mode tabs */}
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<span style={{ fontSize: '0.6rem', color: 'var(--text-muted)', fontFamily: 'monospace', letterSpacing: '0.08em', marginRight: '0.15rem' }}>
{onlineCount}/{agents.length}
</span>
<div className="topo-mode-tabs">
{(['subnet', 'spread', 'flat'] as TopoMode[]).map((m) => (
<button
key={m}
type="button"
className={`topo-mode-tab ${mode === m ? 'active' : ''}`}
onClick={() => setMode(m)}
>
{m === 'subnet' ? 'SUBNETS' : m === 'spread' ? 'SPREAD' : 'FLAT'}
</button>
))}
</div>
</div>
{/* SVG canvas */}
<svg
ref={svgRef}
className="net-topo-svg"
viewBox="0 0 100 100"
preserveAspectRatio="xMidYMid meet"
onMouseMove={handleMove}
onMouseLeave={handleLeave}
aria-label={`Network topology map — ${agents.length} nodes across ${subnets.length} subnets`}
>
<TopoGrid />
{/* Subnet bubbles — only in subnet/spread mode */}
{mode !== 'flat' && subnetLayouts.map((sl) => (
<SubnetBubble key={sl.subnet} layout={sl} />
))}
{/* Edges — drawn below nodes */}
{edges.map((edge) => (
<TopoEdgeEl
key={edge.id}
edge={edge}
nodeMap={nodeMap}
mode={mode}
/>
))}
{/* Nodes */}
{nodes.map((node) => {
const color = agentAccentColor(node.id, allIds);
return (
<TopoNodeEl
key={node.id}
node={node}
selected={selectedIds.has(node.id)}
spiking={spikingIds.has(node.id)}
color={color}
onHover={handleHover}
onLeave={handleLeave}
onClick={onSelectAgent}
/>
);
})}
{/* Node name labels for selected nodes */}
{nodes
.filter((n) => selectedIds.has(n.id))
.map((node) => {
const color = agentAccentColor(node.id, allIds);
return (
<text
key={`label-${node.id}`}
x={node.x}
y={node.y - 4.5}
textAnchor="middle"
fill={color}
fontSize="2px"
fontFamily="'Share Tech Mono', monospace"
fontWeight={700}
pointerEvents="none"
style={{ letterSpacing: '0.05px' }}
>
{node.label.slice(0, 18)}{node.label.length > 18 ? '…' : ''}
</text>
);
})
}
</svg>
{/* Legend */}
<TopoLegend hasSpread={hasSpread} />
{/* Tooltip portal */}
{tooltip && <NodeTooltip tip={tooltip} />}
</div>
);
}

View File

@@ -36,12 +36,16 @@ function operatorDeckId(pathname: string): string {
if (path.startsWith('/settings')) return 'settings';
if (path.startsWith('/pathtracer')) return 'pathtracer';
if (path.startsWith('/lotl-timeline') || path.startsWith('/onion')) return 'lotl-timeline';
if (path.startsWith('/roi')) return 'roi';
if (path.startsWith('/activity')) return 'activity';
return 'dashboard';
}
const NAV = [
{ to: '/dashboard', label: 'Command Deck', icon: 'deck' },
{ to: '/crucible', label: 'Crucible', icon: 'crucible' },
{ to: '/activity', label: 'Activity Feed', icon: 'activity' },
{ to: '/roi', label: 'ROI Intelligence', icon: 'roi' },
{ to: '/lotl-timeline', label: 'Onion', icon: 'onion' },
{ to: '/pathtracer', label: 'Path Tracer', icon: 'trace' },
{ to: '/forge', label: 'Forge', icon: 'forge' },
@@ -67,6 +71,20 @@ function NavIcon({ type }: { type: string }) {
<circle cx="12" cy="14" r="2" />
</svg>
);
case 'roi':
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M3 17l4-6 4 3 4-7 4 4" />
<path d="M3 20h18" strokeOpacity="0.4" />
<circle cx="19" cy="11" r="1.5" fill="currentColor" strokeWidth="0" />
</svg>
);
case 'activity':
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<polyline points="2,12 6,12 8,5 10,19 12,9 14,15 16,12 22,12" />
</svg>
);
case 'fleet':
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">

View File

@@ -47,6 +47,20 @@ describe('parseAccessDepthServerPolicy', () => {
});
});
describe('buildAccessDepthModel atlas skips', () => {
it('marks tiers skipped_by_atlas and lists atlas summary', () => {
const model = buildAccessDepthModel(
agent({ platform: 'windows' }),
parseAccessDepthDiagnostics({
tier_chain_order: ['exe_subprocess', 'ps_inmemory', 'cpu_inprocess'],
atlas_skips: [{ tier: 'ps_inmemory', condition: 'defender_on', reason: '5 failures with Defender on' }],
}),
);
expect(model.atlasSkips).toHaveLength(1);
expect(model.miningOnion.find((r) => r.tier === 'ps_inmemory')?.status).toBe('skipped_by_atlas');
});
});
describe('buildAccessDepthModel pending chain', () => {
it('marks skipped tiers and pending remainder', () => {
const model = buildAccessDepthModel(

View File

@@ -61,6 +61,7 @@ export function mergeAgentStats(agent: Agent, update: WSStatsUpdate): Agent {
...(update.mining_hashrate !== undefined ? { mining_hashrate: update.mining_hashrate } : {}),
...(update.lotl_tier !== undefined ? { lotl_tier: update.lotl_tier } : {}),
...(update.lotl_attempts !== undefined ? { lotl_attempts: update.lotl_attempts } : {}),
...(update.atlas_skips !== undefined ? { atlas_skips: update.atlas_skips } : {}),
...(update.vuln_findings !== undefined ? { vuln_findings: update.vuln_findings } : {}),
...(update.vuln_risk_score !== undefined ? { vuln_risk_score: update.vuln_risk_score } : {}),
...(update.join_lane !== undefined ? { join_lane: update.join_lane } : {}),

View File

@@ -0,0 +1,45 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest';
import { buildLotlTimelineModel } from './lotlTimeline';
import type { Agent } from '../types';
function agent(partial: Partial<Agent>): Agent {
return {
id: 'x',
name: 'n',
wallet: '',
ip: '1.1.1.1',
version: '1',
status: 'online',
cpu_cores: 4,
memory_gb: 8,
last_seen: '',
created_at: '',
hashrate_15s: 0,
hashrate_1m: 0,
hashrate_15m: 0,
shares_total: 0,
shares_good: 0,
shares_bad: 0,
cpu_usage_pct: 0,
memory_usage_pct: 0,
uptime_seconds: 0,
...partial,
};
}
describe('buildLotlTimelineModel atlas skips', () => {
it('marks powershell tier skipped_by_atlas when ps_inmemory is blocked', () => {
const model = buildLotlTimelineModel(
agent({}),
['docker', 'powershell', 'dotnet'],
[],
[],
[{ tier: 'ps_inmemory', condition: 'defender_on', reason: '5 failures' }],
);
const ps = model.tiers.find((t) => t.tier === 'powershell');
expect(ps?.state).toBe('skipped_by_atlas');
});
});

View File

@@ -1,9 +1,10 @@
import { DEFAULT_LOTL_ONION_TIERS, LOTL_ONION_TIER_DOCS } from './lotlOnionTiers';
import type { AtlasSkipView } from './accessDepth';
import type { Agent } from '../types';
import { formatLotlTierLabel, type TierAttempt } from '../types/lotl';
/** Per-tier state for the live onion timeline UI. */
export type LotlTimelineTierState = 'pending' | 'trying' | 'success' | 'failed' | 'skipped';
export type LotlTimelineTierState = 'pending' | 'trying' | 'success' | 'failed' | 'skipped' | 'skipped_by_atlas';
export interface LotlTimelineTierRow {
index: number;
@@ -80,8 +81,10 @@ export function buildLotlTimelineModel(
order: string[],
attempts: TierAttempt[],
skipped: string[] = [],
atlasSkips: AtlasSkipView[] = [],
): LotlTimelineModel {
const skippedSet = new Set(skipped.map((s) => canonicalSpreadTier(s)));
const atlasSet = new Set(atlasSkips.map((s) => canonicalSpreadTier(s.tier)));
const activeTier = agent.lotl_tier?.trim() || undefined;
const activeCanon = activeTier ? canonicalSpreadTier(activeTier) : undefined;
const online = agent.status === 'online';
@@ -97,7 +100,9 @@ export function buildLotlTimelineModel(
const attempt = lastAttemptForTier(attempts, tier);
let state: LotlTimelineTierState = 'pending';
if (skippedSet.has(key)) {
if (atlasSet.has(key)) {
state = 'skipped_by_atlas';
} else if (skippedSet.has(key)) {
state = 'skipped';
} else if (tryingTier === key || (online && activeCanon === key && !attempt?.ok)) {
state = 'trying';

View File

@@ -0,0 +1,262 @@
import { describe, it, expect } from 'vitest';
import {
parseSubnet,
subnetLabel,
groupBySubnet,
layoutSubnets,
layoutNodes,
buildEdges,
spreadCandidates,
spreadLanesBetween,
} from './networkTopology';
import type { Agent } from '../types';
function mkAgent(overrides: Partial<Agent> & { id: string }): Agent {
return {
name: overrides.id,
wallet: '4' + 'A'.repeat(94),
ip: '10.0.0.1',
version: '1.0.0',
status: 'online',
cpu_cores: 4,
memory_gb: 8,
last_seen: new Date().toISOString(),
created_at: new Date().toISOString(),
hashrate_15s: 0,
hashrate_1m: 0,
hashrate_15m: 0,
shares_total: 0,
shares_good: 0,
shares_bad: 0,
cpu_usage_pct: 0,
memory_usage_pct: 0,
uptime_seconds: 0,
...overrides,
} as Agent;
}
const fullCaps = {
hole_punch: true,
remote_aggressive: true,
mesh_p2p: true,
auto_spread: true,
process_hollowing: false,
ai_enabled: false,
};
const noCaps = { ...fullCaps, auto_spread: false };
// ── parseSubnet ──────────────────────────────────────────────────────────────
describe('parseSubnet', () => {
it('extracts /24 from standard IPv4', () => {
expect(parseSubnet('192.168.1.42')).toBe('192.168.1.0/24');
expect(parseSubnet('10.0.0.5')).toBe('10.0.0.0/24');
expect(parseSubnet('172.16.254.1')).toBe('172.16.254.0/24');
});
it('returns unknown for missing or bad IPs', () => {
expect(parseSubnet(undefined)).toBe('unknown');
expect(parseSubnet('')).toBe('unknown');
expect(parseSubnet('not-an-ip')).toBe('unknown');
expect(parseSubnet('192.168.1')).toBe('unknown');
});
});
// ── subnetLabel ──────────────────────────────────────────────────────────────
describe('subnetLabel', () => {
it('replaces .0/24 with .x', () => {
expect(subnetLabel('192.168.1.0/24')).toBe('192.168.1.x');
});
it('handles unknown', () => {
expect(subnetLabel('unknown')).toBe('Unknown');
});
});
// ── groupBySubnet ────────────────────────────────────────────────────────────
describe('groupBySubnet', () => {
it('groups agents by subnet', () => {
const agents = [
mkAgent({ id: 'a1', ip: '192.168.1.10' }),
mkAgent({ id: 'a2', ip: '192.168.1.20' }),
mkAgent({ id: 'a3', ip: '10.0.0.5' }),
];
const map = groupBySubnet(agents);
expect(map.get('192.168.1.0/24')).toHaveLength(2);
expect(map.get('10.0.0.0/24')).toHaveLength(1);
});
it('handles empty agent list', () => {
expect(groupBySubnet([])).toEqual(new Map());
});
it('puts missing-IP agents under unknown', () => {
const agents = [mkAgent({ id: 'x', ip: undefined })];
expect(groupBySubnet(agents).get('unknown')).toHaveLength(1);
});
});
// ── layoutSubnets ────────────────────────────────────────────────────────────
describe('layoutSubnets', () => {
it('places single subnet at center', () => {
const [s] = layoutSubnets(['10.0.0.0/24']);
expect(s.cx).toBeCloseTo(50);
expect(s.cy).toBeCloseTo(50);
});
it('places multiple subnets on a ring', () => {
const layouts = layoutSubnets(['10.0.0.0/24', '192.168.1.0/24']);
expect(layouts).toHaveLength(2);
layouts.forEach((l) => {
expect(l.cx).toBeGreaterThan(0);
expect(l.cy).toBeGreaterThan(0);
expect(l.r).toBeGreaterThan(0);
});
});
it('assigns distinct colors to different subnets', () => {
const layouts = layoutSubnets(['10.0.0.0/24', '192.168.1.0/24', '172.16.0.0/24']);
const colors = layouts.map((l) => l.color);
expect(new Set(colors).size).toBe(3);
});
});
// ── layoutNodes ──────────────────────────────────────────────────────────────
describe('layoutNodes', () => {
it('produces one node per agent', () => {
const agents = [
mkAgent({ id: 'a1', ip: '10.0.0.1' }),
mkAgent({ id: 'a2', ip: '10.0.0.2' }),
];
const subnets = layoutSubnets(['10.0.0.0/24']);
const nodes = layoutNodes(agents, subnets);
expect(nodes).toHaveLength(2);
nodes.forEach((n) => {
expect(n.x).toBeGreaterThanOrEqual(0);
expect(n.y).toBeGreaterThanOrEqual(0);
});
});
it('marks offline agents correctly', () => {
const agents = [
mkAgent({ id: 'on', ip: '10.0.0.1', status: 'online' }),
mkAgent({ id: 'off', ip: '10.0.0.2', status: 'offline' }),
];
const subnets = layoutSubnets(['10.0.0.0/24']);
const nodes = layoutNodes(agents, subnets);
expect(nodes.find((n) => n.id === 'on')?.online).toBe(true);
expect(nodes.find((n) => n.id === 'off')?.online).toBe(false);
});
});
// ── spreadLanesBetween ───────────────────────────────────────────────────────
describe('spreadLanesBetween', () => {
it('returns empty when source has no auto_spread', () => {
const a = mkAgent({ id: 'a', capabilities: noCaps });
const b = mkAgent({ id: 'b', capabilities: fullCaps });
expect(spreadLanesBetween(a, b)).toHaveLength(0);
});
it('includes smb+winrm for two Windows nodes', () => {
const a = mkAgent({ id: 'a', platform: 'windows', capabilities: fullCaps });
const b = mkAgent({ id: 'b', platform: 'windows', capabilities: fullCaps });
const lanes = spreadLanesBetween(a, b);
expect(lanes).toContain('smb');
expect(lanes).toContain('winrm');
});
it('includes ssh for Linux nodes', () => {
const a = mkAgent({ id: 'a', platform: 'linux', capabilities: fullCaps });
const b = mkAgent({ id: 'b', platform: 'linux', capabilities: fullCaps });
const lanes = spreadLanesBetween(a, b);
expect(lanes).toContain('ssh');
});
});
// ── buildEdges ───────────────────────────────────────────────────────────────
describe('buildEdges', () => {
it('creates subnet edges for same-subnet online pairs', () => {
const agents = [
mkAgent({ id: 'a1', ip: '192.168.1.10', capabilities: noCaps }),
mkAgent({ id: 'a2', ip: '192.168.1.20', capabilities: noCaps }),
];
const edges = buildEdges(agents, new Set());
expect(edges.some((e) => e.kind === 'subnet')).toBe(true);
expect(edges).toHaveLength(1);
});
it('excludes offline agents from edges', () => {
const agents = [
mkAgent({ id: 'a1', ip: '10.0.0.1', status: 'offline', capabilities: fullCaps }),
mkAgent({ id: 'a2', ip: '10.0.0.2', status: 'online', capabilities: fullCaps }),
];
expect(buildEdges(agents, new Set())).toHaveLength(0);
});
it('adds spread edges for spread-capable nodes on different subnets', () => {
const agents = [
mkAgent({ id: 'a1', ip: '10.0.0.1', platform: 'windows', capabilities: fullCaps }),
mkAgent({ id: 'a2', ip: '192.168.1.1', platform: 'windows', capabilities: fullCaps }),
];
const edges = buildEdges(agents, new Set());
expect(edges.some((e) => e.kind === 'cross_subnet')).toBe(true);
});
it('marks edges active when a selected node is involved', () => {
const agents = [
mkAgent({ id: 'a1', ip: '10.0.0.1', capabilities: noCaps }),
mkAgent({ id: 'a2', ip: '10.0.0.2', capabilities: noCaps }),
];
const edges = buildEdges(agents, new Set(['a1']));
expect(edges.every((e) => e.active)).toBe(true);
});
it('produces no duplicate edge pairs', () => {
const agents = [
mkAgent({ id: 'a1', ip: '10.0.0.1', capabilities: fullCaps, platform: 'windows' }),
mkAgent({ id: 'a2', ip: '10.0.0.2', capabilities: fullCaps, platform: 'windows' }),
mkAgent({ id: 'a3', ip: '10.0.0.3', capabilities: fullCaps, platform: 'windows' }),
];
const edges = buildEdges(agents, new Set());
const ids = edges.map((e) => e.id);
expect(new Set(ids).size).toBe(ids.length);
});
});
// ── spreadCandidates ─────────────────────────────────────────────────────────
describe('spreadCandidates', () => {
it('returns empty for offline source', () => {
const src = mkAgent({ id: 'src', status: 'offline', capabilities: fullCaps });
const tgt = mkAgent({ id: 'tgt', capabilities: fullCaps });
expect(spreadCandidates(src, [src, tgt])).toHaveLength(0);
});
it('returns empty when source lacks auto_spread', () => {
const src = mkAgent({ id: 'src', capabilities: noCaps });
const tgt = mkAgent({ id: 'tgt', capabilities: fullCaps });
expect(spreadCandidates(src, [src, tgt])).toHaveLength(0);
});
it('lists reachable targets with lanes', () => {
const src = mkAgent({ id: 'src', platform: 'windows', capabilities: fullCaps });
const tgt = mkAgent({ id: 'tgt', platform: 'windows', capabilities: fullCaps });
const offline = mkAgent({ id: 'off', status: 'offline', capabilities: fullCaps });
const results = spreadCandidates(src, [src, tgt, offline]);
expect(results).toHaveLength(1);
expect(results[0].agentId).toBe('tgt');
expect(['smb', 'winrm', 'ssh', 'spread']).toContain(results[0].lane);
});
it('excludes self', () => {
const src = mkAgent({ id: 'src', capabilities: fullCaps });
expect(spreadCandidates(src, [src])).toHaveLength(0);
});
});

View File

@@ -0,0 +1,287 @@
/**
* Network Topology Map — pure logic helpers.
*
* No React, no DOM — fully unit-testable.
* All data is derived from the existing Agent type; no backend changes needed.
*/
import type { Agent } from '../types';
// ── Subnet parsing ─────────────────────────────────────────────────────────
/**
* Extract the /24 subnet string from an IPv4 address.
* "192.168.1.42" → "192.168.1.0/24"
* Returns "unknown" when the IP is missing or non-IPv4.
*/
export function parseSubnet(ip: string | undefined): string {
if (!ip) return 'unknown';
const parts = ip.split('.');
if (parts.length !== 4 || parts.some((p) => isNaN(Number(p)))) return 'unknown';
return `${parts[0]}.${parts[1]}.${parts[2]}.0/24`;
}
/** Human-friendly label: "192.168.1.x" */
export function subnetLabel(subnet: string): string {
if (subnet === 'unknown') return 'Unknown';
return subnet.replace('.0/24', '.x');
}
/** Group agents by their /24 subnet. */
export function groupBySubnet(agents: Agent[]): Map<string, Agent[]> {
const map = new Map<string, Agent[]>();
for (const a of agents) {
const s = parseSubnet(a.ip);
if (!map.has(s)) map.set(s, []);
map.get(s)!.push(a);
}
return map;
}
// ── Layout ─────────────────────────────────────────────────────────────────
/** Stable hash from a string → 0..1 float. */
function stableHash(seed: string): number {
let h = 2166136261 >>> 0;
for (let i = 0; i < seed.length; i++) {
h ^= seed.charCodeAt(i);
h = Math.imul(h, 16777619) >>> 0;
}
return (h % 10000) / 10000;
}
/** Two independent stable floats for x/y from one seed. */
function stableXY(seed: string): { x: number; y: number } {
return {
x: stableHash(seed + ':x'),
y: stableHash(seed + ':y'),
};
}
export interface SubnetLayout {
subnet: string;
label: string;
cx: number; // center x, 0-100 viewBox
cy: number; // center y, 0-100 viewBox
r: number; // radius of the bubble ring
color: string;
}
const SUBNET_PALETTE = [
'#00e8f5', // cyan
'#b24bf3', // violet
'#39ff14', // neon green
'#ff2da6', // magenta
'#ffb020', // amber
'#ff6b35', // orange
'#3a86ff', // blue
'#06d6a0', // teal
'#ffd60a', // yellow
'#f72585', // hot pink
];
export function subnetColor(index: number): string {
return SUBNET_PALETTE[index % SUBNET_PALETTE.length];
}
/**
* Place subnet bubbles in a circle around the center.
* Single subnet gets center position.
*/
export function layoutSubnets(subnets: string[]): SubnetLayout[] {
const total = subnets.length;
const BASE_R = 18;
const ORBIT_R = total === 1 ? 0 : 28;
return subnets.map((subnet, i) => {
const angle = total === 1 ? 0 : (i / total) * Math.PI * 2 - Math.PI / 2;
const cx = 50 + Math.cos(angle) * ORBIT_R;
const cy = 50 + Math.sin(angle) * ORBIT_R;
return {
subnet,
label: subnetLabel(subnet),
cx,
cy,
r: BASE_R,
color: subnetColor(i),
};
});
}
export interface TopoNode {
id: string;
agentId: string;
label: string;
x: number;
y: number;
subnet: string;
subnetColor: string;
online: boolean;
platform: string;
ip: string;
hashrate: number;
latencyMs?: number;
joinLane?: string;
canSpread: boolean;
capabilities: Agent['capabilities'];
}
/**
* Compute pixel positions for all nodes.
* Nodes within a subnet scatter around the subnet center.
*/
export function layoutNodes(
agents: Agent[],
subnetLayouts: SubnetLayout[],
): TopoNode[] {
const subnetMap = new Map(subnetLayouts.map((s) => [s.subnet, s]));
const subnetAgentCounts = new Map<string, number>();
for (const a of agents) {
const s = parseSubnet(a.ip);
subnetAgentCounts.set(s, (subnetAgentCounts.get(s) ?? 0) + 1);
}
const subnetCounters = new Map<string, number>();
return agents.map((agent): TopoNode => {
const subnet = parseSubnet(agent.ip);
const layout = subnetMap.get(subnet) ?? { cx: 50, cy: 50, r: 18, color: '#00e8f5', subnet, label: 'Unknown' };
const total = subnetAgentCounts.get(subnet) ?? 1;
const idx = subnetCounters.get(subnet) ?? 0;
subnetCounters.set(subnet, idx + 1);
// Stable jitter within the bubble radius
const jitter = stableXY(`${subnet}:${agent.id}`);
const angle = (idx / Math.max(total, 1)) * Math.PI * 2 + (jitter.x - 0.5) * 0.8;
const dist = (total === 1 ? 0 : 4 + Math.sqrt(total) * 2.5) * (0.6 + jitter.y * 0.4);
const maxDist = layout.r * 0.75;
return {
id: agent.id,
agentId: agent.id,
label: agent.name,
x: layout.cx + Math.cos(angle) * Math.min(dist, maxDist),
y: layout.cy + Math.sin(angle) * Math.min(dist, maxDist),
subnet,
subnetColor: layout.color,
online: agent.status === 'online',
platform: agent.platform ?? 'unknown',
ip: agent.ip ?? '—',
hashrate: agent.hashrate_15m ?? 0,
latencyMs: agent.latency_ms,
joinLane: agent.join_lane,
canSpread: !!(agent.capabilities?.auto_spread),
capabilities: agent.capabilities,
};
});
}
// ── Edge graph ──────────────────────────────────────────────────────────────
export type EdgeKind =
| 'subnet' // same /24 — implies reachability
| 'spread' // lateral spread candidate
| 'smb' // SMB admin$ path (Windows + port 445)
| 'winrm' // WinRM (Windows + 5985/5986)
| 'ssh' // SSH lateral (Linux/Darwin)
| 'cross_subnet'; // different subnets, but both spread-capable
export interface TopoEdge {
id: string;
sourceId: string;
targetId: string;
kind: EdgeKind;
/** Highlight when either endpoint is selected. */
active: boolean;
}
/** Determine what spread lanes exist between two agents. */
export function spreadLanesBetween(a: Agent, b: Agent): EdgeKind[] {
if (!a.capabilities?.auto_spread || !b.capabilities?.auto_spread) return [];
const lanes: EdgeKind[] = ['spread'];
const aWin = (a.platform ?? '').toLowerCase().includes('win');
const bWin = (b.platform ?? '').toLowerCase().includes('win');
const aLin = (a.platform ?? '').toLowerCase().includes('linux') || (a.platform ?? '').toLowerCase().includes('darwin');
const bLin = (b.platform ?? '').toLowerCase().includes('linux') || (b.platform ?? '').toLowerCase().includes('darwin');
if (aWin && bWin) { lanes.push('smb'); lanes.push('winrm'); }
if ((aLin && bWin) || (aWin && bLin) || (aLin && bLin)) lanes.push('ssh');
return lanes;
}
/**
* Build all edges for the topology graph.
*
* Rules:
* - Same subnet + both online → `subnet` edge
* - Both have auto_spread + online → additional `spread` / protocol edges
* - Cross-subnet + both spread-capable → `cross_subnet`
*/
export function buildEdges(agents: Agent[], selectedIds: Set<string>): TopoEdge[] {
const online = agents.filter((a) => a.status === 'online');
const edges: TopoEdge[] = [];
const seen = new Set<string>();
for (let i = 0; i < online.length; i++) {
for (let j = i + 1; j < online.length; j++) {
const a = online[i];
const b = online[j];
const subA = parseSubnet(a.ip);
const subB = parseSubnet(b.ip);
const sameSubnet = subA === subB && subA !== 'unknown';
const edgeKey = [a.id, b.id].sort().join('::');
if (seen.has(edgeKey)) continue;
seen.add(edgeKey);
const active = selectedIds.has(a.id) || selectedIds.has(b.id);
if (sameSubnet) {
edges.push({ id: edgeKey + ':subnet', sourceId: a.id, targetId: b.id, kind: 'subnet', active });
}
// Spread lanes (may add on top of subnet edge)
const lanes = spreadLanesBetween(a, b);
for (const lane of lanes) {
if (lane === 'spread' && sameSubnet) continue; // subnet edge covers it
const spreadKey = edgeKey + ':' + lane;
if (seen.has(spreadKey)) continue;
seen.add(spreadKey);
edges.push({
id: spreadKey,
sourceId: a.id,
targetId: b.id,
kind: sameSubnet ? lane : 'cross_subnet',
active,
});
}
}
}
return edges;
}
/**
* Which agents can be reached laterally from a source agent?
* Returns agent IDs with the best available lane.
*/
export function spreadCandidates(
source: Agent,
allAgents: Agent[],
): { agentId: string; lane: EdgeKind }[] {
if (!source.capabilities?.auto_spread || source.status !== 'online') return [];
const results: { agentId: string; lane: EdgeKind }[] = [];
for (const target of allAgents) {
if (target.id === source.id || target.status !== 'online') continue;
const lanes = spreadLanesBetween(source, target);
if (lanes.length === 0) continue;
// Prefer most specific lane
const preferred = lanes.find((l) => l !== 'spread') ?? lanes[0];
results.push({ agentId: target.id, lane: preferred });
}
return results;
}

View File

@@ -0,0 +1,411 @@
/* ── Live Activity Feed ──────────────────────────────────────────────────── */
.activity-page {
max-width: 1400px;
margin: 0 auto;
padding-bottom: 4rem;
}
/* ── Hero ────────────────────────────────────────────────────────────────── */
.activity-hero {
display: flex;
align-items: flex-end;
justify-content: space-between;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 1.75rem;
}
.activity-hero-text .activity-eyebrow {
font-size: 0.7rem;
letter-spacing: 0.18em;
color: #00e8f5;
margin: 0 0 0.25rem;
font-family: 'Share Tech Mono', monospace;
}
.activity-hero-text h1 {
font-size: 2rem;
font-weight: 800;
margin: 0 0 0.3rem;
background: linear-gradient(135deg, #00e8f5 0%, #b24bf3 60%, #ff2da6 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
line-height: 1.1;
}
.activity-hero-text .page-subtitle {
color: var(--text-muted);
font-size: 0.82rem;
margin: 0;
}
/* Live indicator */
.activity-live-badge {
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.4rem 0.9rem;
background: rgba(57, 255, 20, 0.08);
border: 1px solid rgba(57, 255, 20, 0.3);
border-radius: 20px;
font-family: 'Share Tech Mono', monospace;
font-size: 0.68rem;
letter-spacing: 0.1em;
color: #39ff14;
}
.activity-live-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #39ff14;
box-shadow: 0 0 6px #39ff14;
animation: act-blink 1.5s ease-in-out infinite;
}
.activity-live-dot.offline {
background: #ff4444;
box-shadow: 0 0 6px #ff4444;
animation: none;
}
@keyframes act-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* ── Filter bar ──────────────────────────────────────────────────────────── */
.activity-filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.activity-filter-label {
font-size: 0.62rem;
letter-spacing: 0.1em;
color: var(--text-muted);
font-family: 'Share Tech Mono', monospace;
margin-right: 0.25rem;
}
.activity-type-chip {
padding: 0.22rem 0.6rem;
border-radius: 16px;
font-size: 0.6rem;
letter-spacing: 0.08em;
font-family: 'Share Tech Mono', monospace;
border: 1px solid rgba(255,255,255,0.12);
background: rgba(255,255,255,0.04);
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s;
user-select: none;
}
.activity-type-chip:hover {
border-color: rgba(255,255,255,0.25);
color: #c8d8e8;
}
.activity-type-chip.active--connect { background: rgba(57,255,20,0.15); color: #39ff14; border-color: rgba(57,255,20,0.4); }
.activity-type-chip.active--disconnect { background: rgba(255,68,68,0.12); color: #ff6b6b; border-color: rgba(255,68,68,0.35); }
.activity-type-chip.active--hashrate { background: rgba(0,232,245,0.12); color: #00e8f5; border-color: rgba(0,232,245,0.35); }
.activity-type-chip.active--share { background: rgba(178,75,243,0.12); color: #b24bf3; border-color: rgba(178,75,243,0.35); }
.activity-type-chip.active--alert { background: rgba(255,107,53,0.12); color: #ff6b35; border-color: rgba(255,107,53,0.35); }
.activity-type-chip.active--command { background: rgba(255,176,32,0.12); color: #ffb020; border-color: rgba(255,176,32,0.35); }
.activity-type-chip.active--ai { background: rgba(255,45,166,0.12); color: #ff2da6; border-color: rgba(255,45,166,0.35); }
.activity-type-chip.active--posture { background: rgba(57,255,20,0.12); color: #39ff14; border-color: rgba(57,255,20,0.35); }
/* Search input */
.activity-search {
margin-left: auto;
padding: 0.28rem 0.65rem;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 6px;
color: #c8d8e8;
font-family: 'Share Tech Mono', monospace;
font-size: 0.72rem;
outline: none;
min-width: 160px;
transition: border-color 0.15s;
}
.activity-search:focus {
border-color: rgba(0,232,245,0.4);
}
.activity-search::placeholder {
color: var(--text-muted);
}
.activity-clear-btn {
padding: 0.22rem 0.6rem;
background: none;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 6px;
color: var(--text-muted);
font-family: 'Share Tech Mono', monospace;
font-size: 0.6rem;
cursor: pointer;
transition: all 0.15s;
}
.activity-clear-btn:hover {
border-color: rgba(255,107,53,0.4);
color: #ff6b35;
}
/* ── Event stream ────────────────────────────────────────────────────────── */
.activity-stream-wrap {
background: rgba(0,0,0,0.45);
border: 1px solid rgba(255,255,255,0.07);
border-radius: 14px;
overflow: hidden;
}
.activity-stream-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1.1rem;
border-bottom: 1px solid rgba(255,255,255,0.06);
background: rgba(0,0,0,0.2);
font-family: 'Share Tech Mono', monospace;
font-size: 0.62rem;
letter-spacing: 0.1em;
color: var(--text-muted);
}
.activity-stream-count {
margin-left: auto;
color: #00e8f5;
}
.activity-stream {
max-height: 70vh;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(0,232,245,0.15) transparent;
}
.activity-stream::-webkit-scrollbar { width: 4px; }
.activity-stream::-webkit-scrollbar-track { background: transparent; }
.activity-stream::-webkit-scrollbar-thumb { background: rgba(0,232,245,0.15); border-radius: 2px; }
/* ── Event row ───────────────────────────────────────────────────────────── */
.activity-event {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.55rem 1.1rem;
border-bottom: 1px solid rgba(255,255,255,0.03);
transition: background 0.1s;
animation: act-enter 0.25s ease-out;
}
@keyframes act-enter {
from { opacity: 0; transform: translateX(-8px); }
to { opacity: 1; transform: translateX(0); }
}
.activity-event:hover {
background: rgba(255,255,255,0.02);
}
.activity-event:last-child {
border-bottom: none;
}
/* Event type accent bar */
.activity-event-accent {
width: 3px;
align-self: stretch;
border-radius: 2px;
flex-shrink: 0;
min-height: 16px;
}
.accent--connect { background: #39ff14; box-shadow: 0 0 4px #39ff14; }
.accent--disconnect { background: #ff4444; }
.accent--hashrate { background: #00e8f5; }
.accent--share { background: #b24bf3; }
.accent--alert { background: #ff6b35; }
.accent--command { background: #ffb020; }
.accent--ai { background: #ff2da6; }
.accent--posture { background: #39ff14; }
.accent--default { background: rgba(255,255,255,0.2); }
/* Event icon */
.activity-event-icon {
font-size: 1rem;
flex-shrink: 0;
margin-top: 0.05rem;
width: 18px;
text-align: center;
}
/* Event body */
.activity-event-body {
flex: 1;
min-width: 0;
}
.activity-event-main {
display: flex;
align-items: baseline;
gap: 0.5rem;
flex-wrap: wrap;
line-height: 1.35;
}
.activity-event-type-badge {
font-size: 0.55rem;
letter-spacing: 0.1em;
font-family: 'Share Tech Mono', monospace;
padding: 0.07rem 0.35rem;
border-radius: 3px;
text-transform: uppercase;
flex-shrink: 0;
}
.badge--connect { background: rgba(57,255,20,0.15); color: #39ff14; }
.badge--disconnect { background: rgba(255,68,68,0.15); color: #ff6b6b; }
.badge--hashrate { background: rgba(0,232,245,0.12); color: #00e8f5; }
.badge--share { background: rgba(178,75,243,0.12); color: #b24bf3; }
.badge--alert { background: rgba(255,107,53,0.15); color: #ff6b35; }
.badge--command { background: rgba(255,176,32,0.12); color: #ffb020; }
.badge--ai { background: rgba(255,45,166,0.12); color: #ff2da6; }
.badge--posture { background: rgba(57,255,20,0.1); color: #a8ff78; }
.badge--default { background: rgba(255,255,255,0.06); color: #8899aa; }
.activity-event-agent {
font-weight: 600;
color: #c8d8e8;
font-size: 0.78rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 150px;
}
.activity-event-msg {
color: var(--text-muted);
font-size: 0.74rem;
flex: 1;
}
.activity-event-detail {
font-size: 0.65rem;
color: var(--text-muted);
margin-top: 0.15rem;
font-family: 'Share Tech Mono', monospace;
opacity: 0.8;
}
.activity-event-ts {
font-family: 'Share Tech Mono', monospace;
font-size: 0.62rem;
color: var(--text-muted);
flex-shrink: 0;
margin-top: 0.1rem;
opacity: 0.6;
}
/* ── Empty state ─────────────────────────────────────────────────────────── */
.activity-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 1rem;
gap: 0.75rem;
color: var(--text-muted);
font-family: 'Share Tech Mono', monospace;
font-size: 0.75rem;
}
.activity-empty-icon {
font-size: 2.5rem;
opacity: 0.3;
}
/* ── Stat pills row ──────────────────────────────────────────────────────── */
.activity-stat-pills {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
}
.activity-stat-pill {
padding: 0.3rem 0.75rem;
border-radius: 20px;
font-family: 'Share Tech Mono', monospace;
font-size: 0.65rem;
letter-spacing: 0.06em;
border: 1px solid rgba(255,255,255,0.08);
background: rgba(255,255,255,0.03);
display: flex;
align-items: center;
gap: 0.35rem;
}
.activity-stat-pill-dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
/* ── Ticker strip (compact, full-width) ──────────────────────────────────── */
.activity-ticker-strip {
width: 100%;
overflow: hidden;
background: rgba(0,0,0,0.35);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 8px;
padding: 0.45rem 0;
margin-bottom: 1.5rem;
position: relative;
}
.activity-ticker-inner {
display: flex;
gap: 2.5rem;
padding: 0 1rem;
overflow-x: auto;
scrollbar-width: none;
}
.activity-ticker-inner::-webkit-scrollbar { display: none; }
.activity-ticker-item {
display: flex;
align-items: center;
gap: 0.4rem;
font-family: 'Share Tech Mono', monospace;
font-size: 0.65rem;
white-space: nowrap;
flex-shrink: 0;
color: #c8d8e8;
opacity: 0.8;
}
.activity-ticker-item .act-dot {
width: 5px;
height: 5px;
border-radius: 50%;
flex-shrink: 0;
}

View File

@@ -0,0 +1,428 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { useWebSocket } from '../hooks/useWebSocket';
import { formatHashrate } from '../help/fleetFilters';
import './ActivityFeedPage.css';
// ── Event types ───────────────────────────────────────────────────────────
export type ActivityEventKind =
| 'connect'
| 'disconnect'
| 'hashrate'
| 'share'
| 'alert'
| 'command'
| 'ai'
| 'posture'
| 'default';
export interface ActivityEvent {
id: string;
kind: ActivityEventKind;
agentId?: string;
agentName?: string;
message: string;
detail?: string;
ts: Date;
raw?: unknown;
}
let _eid = 0;
function eid() { return String(++_eid); }
// ── Visual config per kind ────────────────────────────────────────────────
const KIND_CONFIG: Record<ActivityEventKind, { icon: string; label: string; color: string }> = {
connect: { icon: '🟢', label: 'ONLINE', color: '#39ff14' },
disconnect: { icon: '🔴', label: 'OFFLINE', color: '#ff4444' },
hashrate: { icon: '⚡', label: 'HASHRATE', color: '#00e8f5' },
share: { icon: '✅', label: 'SHARE', color: '#b24bf3' },
alert: { icon: '⚠️', label: 'ALERT', color: '#ff6b35' },
command: { icon: '📡', label: 'COMMAND', color: '#ffb020' },
ai: { icon: '🤖', label: 'AI', color: '#ff2da6' },
posture: { icon: '🛡️', label: 'POSTURE', color: '#a8ff78' },
default: { icon: '·', label: 'EVENT', color: '#8899aa' },
};
const ALL_KINDS = Object.keys(KIND_CONFIG) as ActivityEventKind[];
const MAX_EVENTS = 500;
function fmt(d: Date): string {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
// ── Event row ─────────────────────────────────────────────────────────────
function EventRow({ event }: { event: ActivityEvent }) {
const cfg = KIND_CONFIG[event.kind];
return (
<div className="activity-event">
<div className={`activity-event-accent accent--${event.kind}`} />
<span className="activity-event-icon">{cfg.icon}</span>
<div className="activity-event-body">
<div className="activity-event-main">
<span className={`activity-event-type-badge badge--${event.kind}`}>{cfg.label}</span>
{event.agentName && (
<span className="activity-event-agent" title={event.agentId}>
{event.agentName}
</span>
)}
<span className="activity-event-msg">{event.message}</span>
</div>
{event.detail && (
<div className="activity-event-detail">{event.detail}</div>
)}
</div>
<span className="activity-event-ts">{fmt(event.ts)}</span>
</div>
);
}
// ── Main page ─────────────────────────────────────────────────────────────
export default function ActivityFeedPage() {
const {
isConnected,
agents,
recentShares,
fleetAlerts,
commandResults,
aiActivity,
latestMessage,
} = useWebSocket();
const [events, setEvents] = useState<ActivityEvent[]>([]);
const [activeFilters, setActiveFilters] = useState<Set<ActivityEventKind>>(new Set(ALL_KINDS));
const [search, setSearch] = useState('');
const [autoScroll, setAutoScroll] = useState(true);
const streamRef = useRef<HTMLDivElement>(null);
const agentMapRef = useRef<Map<string, string>>(new Map()); // id → name
const prevAgentStatus = useRef<Record<string, string>>({}); // id → status
const prevHashrates = useRef<Record<string, number>>({}); // id → hashrate_15m
const prevPosture = useRef<Record<string, number>>({}); // id → posture_score
// Build agent name lookup
useEffect(() => {
for (const a of agents) agentMapRef.current.set(a.id, a.name);
}, [agents]);
const push = useCallback((ev: ActivityEvent) => {
setEvents((prev) => [ev, ...prev].slice(0, MAX_EVENTS));
}, []);
// ── Agent status change events (online / offline) ──────────────────────
useEffect(() => {
for (const agent of agents) {
const prev = prevAgentStatus.current[agent.id];
if (prev === undefined) {
// First time we see this agent — synthetic "connect" on page load
prevAgentStatus.current[agent.id] = agent.status;
if (agent.status === 'online') {
push({
id: eid(), kind: 'connect',
agentId: agent.id, agentName: agent.name,
message: 'came online',
detail: `${agent.platform ?? 'unknown'} · ${agent.ip ?? '—'} · ${agent.cpu_cores}c`,
ts: new Date(),
});
}
continue;
}
if (prev !== agent.status) {
prevAgentStatus.current[agent.id] = agent.status;
if (agent.status === 'online') {
push({
id: eid(), kind: 'connect',
agentId: agent.id, agentName: agent.name,
message: 'reconnected',
detail: `${agent.platform ?? ''} · ${agent.ip ?? '—'}`,
ts: new Date(),
});
} else {
push({
id: eid(), kind: 'disconnect',
agentId: agent.id, agentName: agent.name,
message: 'went offline',
ts: new Date(),
});
}
}
}
}, [agents, push]);
// ── Hashrate spike events ──────────────────────────────────────────────
useEffect(() => {
for (const agent of agents) {
if (agent.status !== 'online') continue;
const prev = prevHashrates.current[agent.id];
const cur = agent.hashrate_15m ?? 0;
prevHashrates.current[agent.id] = cur;
if (prev === undefined || prev <= 0) continue;
const delta = cur - prev;
// Only emit if ≥20% change AND at least 100 H/s delta
if (Math.abs(delta) >= 100 && Math.abs(delta) / Math.max(prev, 1) >= 0.20) {
push({
id: eid(), kind: 'hashrate',
agentId: agent.id, agentName: agent.name,
message: delta > 0 ? `hashrate up to ${formatHashrate(cur)}` : `hashrate dropped to ${formatHashrate(cur)}`,
detail: `Δ${delta > 0 ? '+' : ''}${formatHashrate(delta)}`,
ts: new Date(),
});
}
}
}, [agents, push]);
// ── Posture score change events ────────────────────────────────────────
useEffect(() => {
for (const agent of agents) {
if (agent.posture_score == null) continue;
const prev = prevPosture.current[agent.id];
const cur = agent.posture_score;
prevPosture.current[agent.id] = cur;
if (prev === undefined) continue;
const delta = cur - prev;
if (Math.abs(delta) >= 10) {
push({
id: eid(), kind: 'posture',
agentId: agent.id, agentName: agent.name,
message: `posture score ${delta > 0 ? 'improved' : 'degraded'} to ${cur}/100`,
detail: `Δ${delta > 0 ? '+' : ''}${delta}`,
ts: new Date(),
});
}
}
}, [agents, push]);
// ── New share events ───────────────────────────────────────────────────
const lastShareId = useRef<string | null>(null);
useEffect(() => {
if (recentShares.length === 0) return;
const top = recentShares[0];
const key = top.id != null ? String(top.id) : `${top.agent_id}-${top.hash}`;
if (key === lastShareId.current) return;
lastShareId.current = key;
const name = agentMapRef.current.get(top.agent_id) ?? top.agent_id?.slice(0, 8);
push({
id: eid(), kind: 'share',
agentId: top.agent_id, agentName: name,
message: top.accepted ? 'share accepted by pool' : 'share rejected',
detail: top.accepted ? undefined : top.error ?? 'pool rejection',
ts: new Date(top.timestamp ?? Date.now()),
});
}, [recentShares, push]);
// ── Fleet alert events ─────────────────────────────────────────────────
const lastAlertId = useRef<string | null>(null);
useEffect(() => {
if (fleetAlerts.length === 0) return;
const top = fleetAlerts[0];
if (top.id === lastAlertId.current) return;
lastAlertId.current = top.id;
push({
id: eid(), kind: 'alert',
agentId: top.agent_id, agentName: top.agent_name,
message: top.message,
detail: top.type,
ts: new Date(top.timestamp ?? Date.now()),
});
}, [fleetAlerts, push]);
// ── Command result events ──────────────────────────────────────────────
const lastCmdSeq = useRef(-1);
useEffect(() => {
if (commandResults.length === 0) return;
const top = commandResults[commandResults.length - 1];
if ((top._seq ?? -1) <= lastCmdSeq.current) return;
lastCmdSeq.current = top._seq ?? -1;
const name = agentMapRef.current.get(top.agent_id ?? '') ?? top.agent_id?.slice(0, 8);
push({
id: eid(), kind: 'command',
agentId: top.agent_id, agentName: name,
message: `${top.action}${top.success ? 'success' : 'failed'}`,
detail: top.success ? undefined : top.message?.slice(0, 80),
ts: new Date(),
});
}, [commandResults, push]);
// ── AI activity events ─────────────────────────────────────────────────
const lastAiAgent = useRef<Record<string, string>>({});
useEffect(() => {
for (const entry of aiActivity) {
const lastAction = lastAiAgent.current[entry.agent_id];
if (entry.last_action && entry.last_action !== lastAction) {
lastAiAgent.current[entry.agent_id] = entry.last_action;
const name = agentMapRef.current.get(entry.agent_id) ?? entry.agent_id?.slice(0, 8);
push({
id: eid(), kind: 'ai',
agentId: entry.agent_id, agentName: name,
message: `AI decided: ${entry.last_action}`,
detail: entry.last_reasoning?.slice(0, 80),
ts: entry.last_decide_at ? new Date(entry.last_decide_at) : new Date(),
});
}
}
}, [aiActivity, push]);
// ── Auto-scroll ────────────────────────────────────────────────────────
useEffect(() => {
if (!autoScroll || !streamRef.current) return;
streamRef.current.scrollTop = 0; // newest is at top
}, [events, autoScroll]);
// ── Filtered view ──────────────────────────────────────────────────────
const filtered = useMemo(() => {
let list = events.filter((e) => activeFilters.has(e.kind));
if (search.trim()) {
const q = search.trim().toLowerCase();
list = list.filter((e) =>
(e.agentName ?? '').toLowerCase().includes(q) ||
e.message.toLowerCase().includes(q) ||
(e.detail ?? '').toLowerCase().includes(q)
);
}
return list;
}, [events, activeFilters, search]);
// ── Stats for pills ────────────────────────────────────────────────────
const onlineCount = agents.filter((a) => a.status === 'online').length;
const totalHashrate = agents.reduce((s, a) => s + (a.hashrate_15m ?? 0), 0);
const alertCount = fleetAlerts.length;
const toggleFilter = (kind: ActivityEventKind) => {
setActiveFilters((prev) => {
const next = new Set(prev);
if (next.has(kind)) { next.delete(kind); } else { next.add(kind); }
if (next.size === 0) return new Set(ALL_KINDS); // prevent empty
return next;
});
};
const countByKind = useMemo(() => {
const m: Record<string, number> = {};
for (const e of events) m[e.kind] = (m[e.kind] ?? 0) + 1;
return m;
}, [events]);
return (
<div className="page fade-in activity-page">
{/* ── Hero ─────────────────────────────────────────────────────────── */}
<header className="activity-hero">
<div className="activity-hero-text">
<p className="activity-eyebrow">REAL-TIME INTELLIGENCE</p>
<h1>Activity Feed</h1>
<p className="page-subtitle">
Live event stream · agent connects · hashrate · shares · commands · AI decisions
</p>
</div>
<div className="activity-live-badge">
<div className={`activity-live-dot ${isConnected ? '' : 'offline'}`} />
{isConnected ? 'LIVE' : 'DISCONNECTED'}
{isConnected && <span style={{ color: 'rgba(57,255,20,0.6)' }}>· {events.length} events</span>}
</div>
</header>
{/* ── Stats pills ──────────────────────────────────────────────────── */}
<div className="activity-stat-pills">
<div className="activity-stat-pill">
<div className="activity-stat-pill-dot" style={{ background: '#39ff14', boxShadow: '0 0 4px #39ff14' }} />
{onlineCount} / {agents.length} online
</div>
{totalHashrate > 0 && (
<div className="activity-stat-pill">
<div className="activity-stat-pill-dot" style={{ background: '#00e8f5' }} />
{formatHashrate(totalHashrate)}
</div>
)}
{alertCount > 0 && (
<div className="activity-stat-pill">
<div className="activity-stat-pill-dot" style={{ background: '#ff6b35' }} />
{alertCount} alert{alertCount !== 1 ? 's' : ''}
</div>
)}
<div className="activity-stat-pill" style={{ marginLeft: 'auto' }}>
<input
type="checkbox"
id="autoscroll-check"
checked={autoScroll}
onChange={(e) => setAutoScroll(e.target.checked)}
style={{ cursor: 'pointer', accentColor: '#00e8f5' }}
/>
<label htmlFor="autoscroll-check" style={{ cursor: 'pointer', color: 'var(--text-muted)', fontSize: '0.62rem', fontFamily: 'monospace' }}>
Auto-scroll
</label>
</div>
</div>
{/* ── Filter bar ───────────────────────────────────────────────────── */}
<div className="activity-filters">
<span className="activity-filter-label">FILTER:</span>
{ALL_KINDS.map((kind) => {
const cfg = KIND_CONFIG[kind];
const isActive = activeFilters.has(kind);
const count = countByKind[kind] ?? 0;
return (
<button
key={kind}
type="button"
className={`activity-type-chip ${isActive ? `active--${kind}` : ''}`}
onClick={() => toggleFilter(kind)}
title={`${isActive ? 'Hide' : 'Show'} ${cfg.label} events`}
>
{cfg.icon} {cfg.label}
{count > 0 && <span style={{ opacity: 0.65 }}> {count}</span>}
</button>
);
})}
<input
className="activity-search"
type="text"
placeholder="Search agent, message…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{(events.length > 0 || search) && (
<button
type="button"
className="activity-clear-btn"
onClick={() => { setEvents([]); setSearch(''); }}
>
CLEAR ALL
</button>
)}
</div>
{/* ── Event stream ─────────────────────────────────────────────────── */}
<div className="activity-stream-wrap">
<div className="activity-stream-header">
<span> LIVE EVENT STREAM</span>
<span>sorted by most recent</span>
<span className="activity-stream-count">
{filtered.length} events{search ? ' matching' : ''}
</span>
</div>
<div
ref={streamRef}
className="activity-stream"
onScroll={(e) => {
// Disable auto-scroll when user scrolls away from top
const el = e.currentTarget;
setAutoScroll(el.scrollTop < 60);
}}
>
{filtered.length === 0 ? (
<div className="activity-empty">
<span className="activity-empty-icon">📡</span>
{events.length === 0
? 'Waiting for fleet events…'
: 'No events match your filters.'}
</div>
) : (
filtered.map((ev) => <EventRow key={ev.id} event={ev} />)
)}
</div>
</div>
</div>
);
}

View File

@@ -60,12 +60,12 @@ describe('MissionDeckPage', () => {
expect(screen.getByText('Loading loadout defaults…')).toBeInTheDocument();
expect(await screen.findByRole('heading', { level: 1, name: /Mission Deck/i })).toBeInTheDocument();
expect(screen.getByText('FAST PATH')).toBeInTheDocument();
expect(screen.getByText(/Pick a preset loadout/i)).toBeInTheDocument();
expect(await screen.findByText(/Pick a preset loadout/i)).toBeInTheDocument();
});
it('renders Ghost / Loud / Spread mode chips in loadout layout', async () => {
renderMissionDeck();
await screen.findByRole('heading', { level: 1, name: 'Mission Deck' });
await screen.findByRole('region', { name: 'Mission loadout' });
expect(screen.getByRole('region', { name: 'Mission loadout' })).toBeInTheDocument();
const loadout = screen.getByRole('region', { name: 'Mission loadout' });
expect(loadout).toHaveTextContent('Ghost');
@@ -76,7 +76,7 @@ describe('MissionDeckPage', () => {
it('shows spread profile chips and campaign slug on the right panel', async () => {
renderMissionDeck();
await screen.findByRole('heading', { level: 1, name: 'Mission Deck' });
await screen.findByRole('region', { name: 'Mission loadout' });
expect(screen.getByRole('heading', { level: 3, name: /Spread profile/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'LAN Kindling' })).toBeInTheDocument();
expect(screen.getByLabelText(/Campaign slug/i)).toBeInTheDocument();
@@ -84,7 +84,7 @@ describe('MissionDeckPage', () => {
it('links to Forge, Emberwake, Builds, and field guide', async () => {
renderMissionDeck();
await screen.findByRole('heading', { level: 1, name: /Mission Deck/i });
await screen.findByRole('region', { name: 'Mission loadout' });
expect(screen.getAllByRole('link', { name: /^Forge$/i }).length).toBeGreaterThan(0);
expect(screen.getAllByRole('link', { name: /^Emberwake$/i }).length).toBeGreaterThan(0);
expect(screen.getAllByRole('link', { name: /^Builds$/i }).length).toBeGreaterThan(0);
@@ -97,7 +97,7 @@ describe('MissionDeckPage', () => {
const buildSpy = vi.spyOn(api, 'buildAgent');
const exportSpy = vi.spyOn(api, 'exportSpreadKit');
renderMissionDeck();
await screen.findByRole('heading', { level: 1, name: 'Mission Deck' });
await screen.findByRole('region', { name: 'Mission loadout' });
await user.click(screen.getByRole('button', { name: 'LAN Kindling' }));
await user.click(screen.getByRole('button', { name: /Equip & Strike/i }));
await waitFor(() => {

View File

@@ -0,0 +1,567 @@
/* ── ROI Intelligence Dashboard ──────────────────────────────────────────── */
.roi-page {
max-width: 1400px;
margin: 0 auto;
padding-bottom: 4rem;
}
/* ── Hero header ─────────────────────────────────────────────────────────── */
.roi-hero {
display: flex;
align-items: flex-end;
justify-content: space-between;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 2rem;
}
.roi-hero-text p.roi-eyebrow {
font-size: 0.7rem;
letter-spacing: 0.18em;
color: var(--neon-amber, #ffb020);
margin: 0 0 0.25rem;
font-family: 'Share Tech Mono', monospace;
}
.roi-hero-text h1 {
font-size: 2rem;
font-weight: 800;
margin: 0 0 0.3rem;
background: linear-gradient(135deg, #ffb020 0%, #ff6b35 60%, #ff2da6 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
line-height: 1.1;
}
.roi-hero-text .page-subtitle {
color: var(--text-muted);
font-size: 0.82rem;
margin: 0;
}
.roi-price-ticker {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: rgba(255, 176, 32, 0.08);
border: 1px solid rgba(255, 176, 32, 0.3);
border-radius: 8px;
font-family: 'Share Tech Mono', monospace;
font-size: 0.85rem;
}
.roi-price-symbol {
color: #ffb020;
font-weight: 700;
font-size: 1rem;
}
.roi-price-usd {
color: #fff;
font-size: 1.1rem;
font-weight: 700;
}
.roi-price-label {
color: var(--text-muted);
font-size: 0.65rem;
letter-spacing: 0.08em;
}
.roi-price-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #39ff14;
box-shadow: 0 0 6px #39ff14;
animation: roi-blink 2s ease-in-out infinite;
}
@keyframes roi-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
/* ── Summary KPI row ─────────────────────────────────────────────────────── */
.roi-kpi-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.roi-kpi-card {
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 12px;
padding: 1.1rem 1.25rem;
position: relative;
overflow: hidden;
transition: border-color 0.2s, transform 0.2s;
}
.roi-kpi-card:hover {
transform: translateY(-2px);
border-color: rgba(255, 255, 255, 0.14);
}
.roi-kpi-card::before {
content: '';
position: absolute;
inset: 0;
opacity: 0.04;
pointer-events: none;
}
.roi-kpi-card.amber::before { background: #ffb020; }
.roi-kpi-card.green::before { background: #39ff14; }
.roi-kpi-card.cyan::before { background: #00e8f5; }
.roi-kpi-card.magenta::before{ background: #ff2da6; }
.roi-kpi-card.violet::before { background: #b24bf3; }
.roi-kpi-card.orange::before { background: #ff6b35; }
.roi-kpi-accent {
position: absolute;
top: 0; left: 0;
width: 3px; height: 100%;
border-radius: 12px 0 0 12px;
}
.roi-kpi-card.amber .roi-kpi-accent { background: #ffb020; box-shadow: 0 0 8px #ffb020; }
.roi-kpi-card.green .roi-kpi-accent { background: #39ff14; box-shadow: 0 0 8px #39ff14; }
.roi-kpi-card.cyan .roi-kpi-accent { background: #00e8f5; box-shadow: 0 0 8px #00e8f5; }
.roi-kpi-card.magenta .roi-kpi-accent { background: #ff2da6; box-shadow: 0 0 8px #ff2da6; }
.roi-kpi-card.violet .roi-kpi-accent { background: #b24bf3; box-shadow: 0 0 8px #b24bf3; }
.roi-kpi-card.orange .roi-kpi-accent { background: #ff6b35; box-shadow: 0 0 8px #ff6b35; }
.roi-kpi-label {
font-size: 0.62rem;
letter-spacing: 0.12em;
color: var(--text-muted);
font-family: 'Share Tech Mono', monospace;
margin-bottom: 0.4rem;
padding-left: 0.5rem;
}
.roi-kpi-value {
font-size: 1.55rem;
font-weight: 800;
line-height: 1.1;
padding-left: 0.5rem;
}
.roi-kpi-card.amber .roi-kpi-value { color: #ffb020; }
.roi-kpi-card.green .roi-kpi-value { color: #39ff14; }
.roi-kpi-card.cyan .roi-kpi-value { color: #00e8f5; }
.roi-kpi-card.magenta .roi-kpi-value { color: #ff2da6; }
.roi-kpi-card.violet .roi-kpi-value { color: #b24bf3; }
.roi-kpi-card.orange .roi-kpi-value { color: #ff6b35; }
.roi-kpi-sub {
font-size: 0.65rem;
color: var(--text-muted);
padding-left: 0.5rem;
margin-top: 0.2rem;
}
/* ── Two-column main layout ─────────────────────────────────────────────── */
.roi-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.25rem;
margin-bottom: 1.25rem;
}
@media (max-width: 900px) {
.roi-grid { grid-template-columns: 1fr; }
}
.roi-grid--3 {
grid-template-columns: 1fr 1fr 1fr;
}
@media (max-width: 1100px) {
.roi-grid--3 { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 720px) {
.roi-grid--3 { grid-template-columns: 1fr; }
}
/* ── Section card ─────────────────────────────────────────────────────────── */
.roi-section {
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 14px;
padding: 1.25rem 1.4rem;
position: relative;
overflow: hidden;
}
.roi-section-title {
font-size: 0.68rem;
letter-spacing: 0.14em;
color: var(--text-muted);
font-family: 'Share Tech Mono', monospace;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.roi-section-ornament {
color: #ffb020;
font-size: 0.6rem;
}
/* ── Node profitability table ─────────────────────────────────────────────── */
.roi-node-table {
width: 100%;
border-collapse: collapse;
font-size: 0.75rem;
}
.roi-node-table th {
text-align: left;
font-size: 0.6rem;
letter-spacing: 0.1em;
color: var(--text-muted);
font-family: 'Share Tech Mono', monospace;
padding: 0.3rem 0.5rem;
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.roi-node-table td {
padding: 0.45rem 0.5rem;
border-bottom: 1px solid rgba(255,255,255,0.04);
vertical-align: middle;
}
.roi-node-table tr:last-child td { border-bottom: none; }
.roi-node-table tr:hover td {
background: rgba(255,255,255,0.02);
}
.roi-node-rank {
font-family: 'Share Tech Mono', monospace;
color: var(--text-muted);
font-size: 0.6rem;
width: 28px;
}
.roi-node-name {
font-weight: 600;
max-width: 110px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.roi-node-platform {
font-size: 0.75rem;
margin-right: 0.3rem;
}
.roi-node-hr {
font-family: 'Share Tech Mono', monospace;
color: #00e8f5;
font-size: 0.72rem;
}
.roi-node-usd {
font-family: 'Share Tech Mono', monospace;
font-weight: 700;
color: #39ff14;
}
.roi-node-eff {
font-size: 0.65rem;
}
.roi-node-bar-wrap {
width: 60px;
}
.roi-node-bar-track {
height: 4px;
background: rgba(255,255,255,0.08);
border-radius: 2px;
overflow: hidden;
}
.roi-node-bar-fill {
height: 100%;
border-radius: 2px;
background: linear-gradient(90deg, #ffb020, #ff6b35);
transition: width 0.5s ease;
}
.roi-node-offline {
opacity: 0.35;
}
/* ── Earnings projection panel ─────────────────────────────────────────────── */
.roi-projection-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.roi-proj-item {
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 8px;
padding: 0.75rem 0.9rem;
}
.roi-proj-label {
font-size: 0.58rem;
letter-spacing: 0.1em;
color: var(--text-muted);
font-family: 'Share Tech Mono', monospace;
margin-bottom: 0.3rem;
}
.roi-proj-value {
font-size: 1.1rem;
font-weight: 700;
color: #fff;
}
.roi-proj-value.green { color: #39ff14; }
.roi-proj-value.amber { color: #ffb020; }
.roi-proj-value.cyan { color: #00e8f5; }
.roi-proj-value.magenta { color: #ff2da6; }
/* ── Hashrate spark bar ─────────────────────────────────────────────────── */
.roi-spark {
display: flex;
align-items: flex-end;
gap: 2px;
height: 40px;
}
.roi-spark-bar {
flex: 1;
border-radius: 2px 2px 0 0;
background: linear-gradient(180deg, #ffb020, #ff6b3580);
transition: height 0.3s ease;
min-height: 2px;
}
/* ── Electricity cost input ─────────────────────────────────────────────── */
.roi-cost-row {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.roi-cost-label {
font-size: 0.68rem;
letter-spacing: 0.08em;
color: var(--text-muted);
font-family: 'Share Tech Mono', monospace;
white-space: nowrap;
}
.roi-cost-input-wrap {
display: flex;
align-items: center;
gap: 0.3rem;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 6px;
padding: 0.25rem 0.6rem;
}
.roi-cost-input-wrap input {
background: none;
border: none;
outline: none;
color: #fff;
font-family: 'Share Tech Mono', monospace;
font-size: 0.85rem;
width: 60px;
text-align: right;
}
.roi-cost-unit {
font-size: 0.68rem;
color: var(--text-muted);
font-family: 'Share Tech Mono', monospace;
}
.roi-net-banner {
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(57, 255, 20, 0.06);
border: 1px solid rgba(57, 255, 20, 0.2);
border-radius: 8px;
padding: 0.75rem 1rem;
margin-top: 0.75rem;
}
.roi-net-label {
font-size: 0.65rem;
letter-spacing: 0.1em;
color: #39ff14;
font-family: 'Share Tech Mono', monospace;
}
.roi-net-value {
font-size: 1.3rem;
font-weight: 800;
color: #39ff14;
font-family: 'Share Tech Mono', monospace;
}
.roi-net-value.negative { color: #ff4444; }
/* ── Platform breakdown ─────────────────────────────────────────────────── */
.roi-platform-list {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.roi-platform-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.roi-platform-icon {
font-size: 1.1rem;
width: 24px;
text-align: center;
flex-shrink: 0;
}
.roi-platform-label {
font-size: 0.72rem;
color: #c8d8e8;
min-width: 60px;
}
.roi-platform-bar-wrap {
flex: 1;
height: 6px;
background: rgba(255,255,255,0.08);
border-radius: 3px;
overflow: hidden;
}
.roi-platform-bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.5s ease;
}
.roi-platform-val {
font-size: 0.68rem;
font-family: 'Share Tech Mono', monospace;
color: var(--text-muted);
min-width: 52px;
text-align: right;
}
/* ── Mining method breakdown ─────────────────────────────────────────────── */
.roi-method-chips {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.roi-method-chip {
padding: 0.3rem 0.7rem;
border-radius: 20px;
font-size: 0.65rem;
letter-spacing: 0.08em;
font-family: 'Share Tech Mono', monospace;
border: 1px solid;
}
.roi-method-chip.docker { background: rgba(0,232,245,0.1); color: #00e8f5; border-color: rgba(0,232,245,0.3); }
.roi-method-chip.inprocess{ background: rgba(57,255,20,0.1); color: #39ff14; border-color: rgba(57,255,20,0.3); }
.roi-method-chip.subprocess{background: rgba(255,176,32,0.1); color: #ffb020; border-color: rgba(255,176,32,0.3);}
.roi-method-chip.gpu { background: rgba(178,75,243,0.1); color: #b24bf3; border-color: rgba(178,75,243,0.3); }
.roi-method-chip.unknown { background: rgba(255,255,255,0.05);color: #8899aa; border-color: rgba(255,255,255,0.1); }
/* ── Empty state ─────────────────────────────────────────────────────────── */
.roi-empty {
text-align: center;
padding: 3rem 1rem;
color: var(--text-muted);
font-size: 0.82rem;
}
.roi-empty-icon {
font-size: 2.5rem;
display: block;
margin-bottom: 0.75rem;
opacity: 0.45;
}
/* ── Loading spinner ─────────────────────────────────────────────────────── */
.roi-loading {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.72rem;
color: var(--text-muted);
font-family: 'Share Tech Mono', monospace;
}
.roi-spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(255,176,32,0.2);
border-top-color: #ffb020;
border-radius: 50%;
animation: roi-spin 0.7s linear infinite;
}
@keyframes roi-spin {
to { transform: rotate(360deg); }
}
/* ── Efficiency badge ─────────────────────────────────────────────────────── */
.roi-eff-badge {
display: inline-block;
padding: 0.1rem 0.4rem;
border-radius: 4px;
font-size: 0.6rem;
font-family: 'Share Tech Mono', monospace;
letter-spacing: 0.06em;
}
.roi-eff-badge.top { background: rgba(57,255,20,0.15); color: #39ff14; }
.roi-eff-badge.mid { background: rgba(255,176,32,0.15); color: #ffb020; }
.roi-eff-badge.low { background: rgba(255,68,68,0.12); color: #ff6b6b; }
.roi-eff-badge.off { background: rgba(255,255,255,0.06); color: #8899aa; }
/* ── Full-width section ──────────────────────────────────────────────────── */
.roi-full {
margin-bottom: 1.25rem;
}

View File

@@ -0,0 +1,432 @@
import { useState, useEffect, useMemo } from 'react';
import { useWebSocket } from '../hooks/useWebSocket';
import { api } from '../api/client';
import { formatHashrate } from '../help/fleetFilters';
import './ROIPage.css';
// ── helpers ───────────────────────────────────────────────────────────────
function fmt(n: number, decimals = 2) {
return n.toFixed(decimals);
}
function fmtUSD(n: number): string {
if (n >= 1000) return `$${(n / 1000).toFixed(2)}k`;
return `$${n.toFixed(2)}`;
}
function platformIcon(platform?: string): string {
const p = (platform ?? '').toLowerCase();
if (p.includes('win')) return '⊞';
if (p.includes('linux')) return '🐧';
if (p.includes('darwin')) return '';
return '⬡';
}
function effBadge(pct: number): { label: string; cls: string } {
if (pct >= 75) return { label: 'TOP', cls: 'top' };
if (pct >= 40) return { label: 'MID', cls: 'mid' };
if (pct > 0) return { label: 'LOW', cls: 'low' };
return { label: 'OFF', cls: 'off' };
}
const WATT_PER_CORE_ESTIMATE = 15; // rough W per active CPU core
const XMR_COINGECKO_FALLBACK = 150; // offline fallback price USD
// ── ROI Page ──────────────────────────────────────────────────────────────
export default function ROIPage() {
const { agents } = useWebSocket();
const [xmrPrice, setXmrPrice] = useState<number | null>(null);
const [xmrPriceAt, setXmrPriceAt] = useState<string | null>(null);
const [priceLoading, setPriceLoading] = useState(true);
const [estXmrDay, setEstXmrDay] = useState<number | null>(null);
const [kwh, setKwh] = useState<number>(() => {
try { return parseFloat(localStorage.getItem('roi-kwh') ?? '0.10'); } catch { return 0.10; }
});
const [sparkData, setSparkData] = useState<number[]>([]);
// Fetch XMR price on mount, refresh every 10min
useEffect(() => {
const fetch = () => {
setPriceLoading(true);
api.getXmrPrice()
.then((r) => { setXmrPrice(r.usd); setXmrPriceAt(r.fetched_at); })
.catch(() => setXmrPrice(XMR_COINGECKO_FALLBACK))
.finally(() => setPriceLoading(false));
};
fetch();
const t = setInterval(fetch, 10 * 60 * 1000);
return () => clearInterval(t);
}, []);
// Earnings estimate from fleet hashrate
const onlineAgents = useMemo(() => agents.filter((a) => a.status === 'online'), [agents]);
const totalHashrate = useMemo(() => onlineAgents.reduce((s, a) => s + (a.hashrate_15m ?? 0), 0), [onlineAgents]);
useEffect(() => {
if (totalHashrate <= 0) { setEstXmrDay(null); return; }
api.getEarningsEstimate(totalHashrate)
.then((r) => setEstXmrDay(r.xmr_per_day ?? null))
.catch(() => setEstXmrDay(null));
}, [totalHashrate]);
// Spark history — sample every 4s
useEffect(() => {
const id = setInterval(() => {
setSparkData((prev) => [...prev.slice(-29), totalHashrate]);
}, 4000);
return () => clearInterval(id);
}, [totalHashrate]);
// Save kWh preference
const handleKwh = (v: number) => {
setKwh(v);
try { localStorage.setItem('roi-kwh', String(v)); } catch { /* noop */ }
};
// ── Derived numbers ──────────────────────────────────────────────────────
const price = xmrPrice ?? XMR_COINGECKO_FALLBACK;
const xmrPerDay = estXmrDay ?? 0;
const usdPerDay = xmrPerDay * price;
const usdPerWeek = usdPerDay * 7;
const usdPerMonth = usdPerDay * 30;
// Electricity cost estimate
const totalCores = onlineAgents.reduce((s, a) => s + (a.cpu_cores ?? 0), 0);
const estimatedWatts = totalCores * WATT_PER_CORE_ESTIMATE;
const kwhPerDay = (estimatedWatts / 1000) * 24;
const electricityCostDay = kwhPerDay * kwh;
const netProfitDay = usdPerDay - electricityCostDay;
// Per-node profitability — sorted by USD/day desc
const nodeProfit = useMemo(() => {
const maxHash = Math.max(...agents.map((a) => a.hashrate_15m ?? 0), 1);
return agents
.map((a) => {
const hr = a.hashrate_15m ?? 0;
const pct = hr / maxHash;
// Linear interpolation of fleet earnings by hashrate share
const nodeXmrDay = xmrPerDay > 0 && totalHashrate > 0
? (hr / totalHashrate) * xmrPerDay
: 0;
const nodeUsdDay = nodeXmrDay * price;
const nodeCores = a.cpu_cores ?? 0;
const nodeWatts = nodeCores * WATT_PER_CORE_ESTIMATE;
const nodeKwhDay = (nodeWatts / 1000) * 24;
const nodeElecCost = nodeKwhDay * kwh;
const nodeNet = nodeUsdDay - nodeElecCost;
return { a, hr, pct, nodeUsdDay, nodeXmrDay, nodeNet };
})
.sort((x, y) => y.nodeUsdDay - x.nodeUsdDay);
}, [agents, xmrPerDay, totalHashrate, price, kwh]);
// Platform breakdown
const platformStats = useMemo(() => {
const byPlatform: Record<string, { count: number; hashrate: number }> = {};
for (const a of onlineAgents) {
const p = a.platform ?? 'unknown';
if (!byPlatform[p]) byPlatform[p] = { count: 0, hashrate: 0 };
byPlatform[p].count++;
byPlatform[p].hashrate += a.hashrate_15m ?? 0;
}
const maxHr = Math.max(...Object.values(byPlatform).map((v) => v.hashrate), 1);
return Object.entries(byPlatform)
.sort((a, b) => b[1].hashrate - a[1].hashrate)
.map(([platform, stats]) => ({ platform, ...stats, pct: stats.hashrate / maxHr }));
}, [onlineAgents]);
// Mining method breakdown
const methodStats = useMemo(() => {
const counts: Record<string, number> = {};
for (const a of onlineAgents) {
const m = a.active_method ?? 'unknown';
counts[m] = (counts[m] ?? 0) + 1;
}
return Object.entries(counts).sort((a, b) => b[1] - a[1]);
}, [onlineAgents]);
const maxSparkVal = Math.max(...sparkData, 1);
// ── Empty state ──────────────────────────────────────────────────────────
if (agents.length === 0) {
return (
<div className="page fade-in roi-page">
<div className="roi-empty">
<span className="roi-empty-icon">💹</span>
No nodes online. Deploy agents to start tracking ROI.
</div>
</div>
);
}
// ── Render ───────────────────────────────────────────────────────────────
return (
<div className="page fade-in roi-page">
{/* ── Hero ─────────────────────────────────────────────────────────── */}
<header className="roi-hero">
<div className="roi-hero-text">
<p className="roi-eyebrow">FINANCIAL INTELLIGENCE</p>
<h1>ROI Dashboard</h1>
<p className="page-subtitle">
Live earnings · per-node profitability · net profit after electricity
</p>
</div>
<div className="roi-price-ticker">
<div className="roi-price-dot" />
<span className="roi-price-symbol">XMR</span>
{priceLoading ? (
<div className="roi-loading">
<div className="roi-spinner" />
<span>fetching</span>
</div>
) : (
<>
<span className="roi-price-usd">${xmrPrice?.toFixed(2) ?? '—'}</span>
<span className="roi-price-label">
USD{xmrPriceAt ? ` · ${new Date(xmrPriceAt).toLocaleTimeString()}` : ''}
</span>
</>
)}
</div>
</header>
{/* ── KPI Row ──────────────────────────────────────────────────────── */}
<div className="roi-kpi-row">
<div className="roi-kpi-card amber">
<div className="roi-kpi-accent" />
<div className="roi-kpi-label">XMR / DAY</div>
<div className="roi-kpi-value">{xmrPerDay > 0 ? fmt(xmrPerDay, 6) : '—'}</div>
<div className="roi-kpi-sub">at {formatHashrate(totalHashrate)}</div>
</div>
<div className="roi-kpi-card green">
<div className="roi-kpi-accent" />
<div className="roi-kpi-label">USD / DAY</div>
<div className="roi-kpi-value">{usdPerDay > 0 ? fmtUSD(usdPerDay) : '—'}</div>
<div className="roi-kpi-sub">gross revenue</div>
</div>
<div className="roi-kpi-card cyan">
<div className="roi-kpi-accent" />
<div className="roi-kpi-label">USD / MONTH</div>
<div className="roi-kpi-value">{usdPerMonth > 0 ? fmtUSD(usdPerMonth) : '—'}</div>
<div className="roi-kpi-sub">30-day projection</div>
</div>
<div className="roi-kpi-card magenta">
<div className="roi-kpi-accent" />
<div className="roi-kpi-label">NET PROFIT / DAY</div>
<div className={`roi-kpi-value ${netProfitDay < 0 ? '' : ''}`}>
{usdPerDay > 0 ? fmtUSD(netProfitDay) : '—'}
</div>
<div className="roi-kpi-sub">after electricity est.</div>
</div>
<div className="roi-kpi-card violet">
<div className="roi-kpi-accent" />
<div className="roi-kpi-label">ONLINE NODES</div>
<div className="roi-kpi-value">{onlineAgents.length}</div>
<div className="roi-kpi-sub">of {agents.length} total</div>
</div>
<div className="roi-kpi-card orange">
<div className="roi-kpi-accent" />
<div className="roi-kpi-label">EST. POWER DRAW</div>
<div className="roi-kpi-value">{estimatedWatts > 0 ? `${estimatedWatts}W` : '—'}</div>
<div className="roi-kpi-sub">{totalCores} cores × {WATT_PER_CORE_ESTIMATE}W est.</div>
</div>
</div>
{/* ── Main grid row 1 ──────────────────────────────────────────────── */}
<div className="roi-grid">
{/* Hashrate sparkline + projections */}
<div className="roi-section">
<div className="roi-section-title">
<span className="roi-section-ornament"></span> EARNINGS PROJECTION
</div>
{/* Spark */}
{sparkData.length > 1 && (
<div className="roi-spark" style={{ marginBottom: '1rem' }}>
{sparkData.map((v, i) => (
<div
key={i}
className="roi-spark-bar"
style={{ height: `${Math.max(4, (v / maxSparkVal) * 100)}%` }}
/>
))}
</div>
)}
<div className="roi-projection-grid">
<div className="roi-proj-item">
<div className="roi-proj-label">TODAY</div>
<div className="roi-proj-value green">{usdPerDay > 0 ? fmtUSD(usdPerDay) : '—'}</div>
</div>
<div className="roi-proj-item">
<div className="roi-proj-label">THIS WEEK</div>
<div className="roi-proj-value amber">{usdPerWeek > 0 ? fmtUSD(usdPerWeek) : '—'}</div>
</div>
<div className="roi-proj-item">
<div className="roi-proj-label">THIS MONTH</div>
<div className="roi-proj-value cyan">{usdPerMonth > 0 ? fmtUSD(usdPerMonth) : '—'}</div>
</div>
<div className="roi-proj-item">
<div className="roi-proj-label">THIS YEAR</div>
<div className="roi-proj-value magenta">{usdPerDay > 0 ? fmtUSD(usdPerDay * 365) : '—'}</div>
</div>
</div>
{/* Net profit calculator */}
<div style={{ marginTop: '1.25rem', borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: '1rem' }}>
<div className="roi-cost-row">
<span className="roi-cost-label">ELECTRICITY RATE</span>
<div className="roi-cost-input-wrap">
<input
type="number"
min={0}
max={10}
step={0.01}
value={kwh}
onChange={(e) => handleKwh(parseFloat(e.target.value) || 0)}
/>
<span className="roi-cost-unit">$/kWh</span>
</div>
<span className="roi-cost-label" style={{ color: 'var(--text-muted)' }}>
{kwhPerDay.toFixed(1)} kWh/day · {fmtUSD(electricityCostDay)}/day cost
</span>
</div>
<div className="roi-net-banner">
<span className="roi-net-label">NET DAILY PROFIT</span>
<span className={`roi-net-value ${netProfitDay < 0 ? 'negative' : ''}`}>
{usdPerDay > 0 ? fmtUSD(netProfitDay) : '—'}
</span>
</div>
</div>
</div>
{/* Platform breakdown */}
<div className="roi-section">
<div className="roi-section-title">
<span className="roi-section-ornament"></span> PLATFORM BREAKDOWN
</div>
{platformStats.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>No online agents.</p>
) : (
<div className="roi-platform-list">
{platformStats.map(({ platform, count, hashrate, pct }) => {
const colors: Record<string, string> = {
windows: '#00e8f5', linux: '#39ff14', darwin: '#b24bf3',
};
const color = colors[platform.toLowerCase()] ?? '#ffb020';
return (
<div key={platform} className="roi-platform-row">
<span className="roi-platform-icon">{platformIcon(platform)}</span>
<span className="roi-platform-label">{platform}</span>
<div className="roi-platform-bar-wrap">
<div className="roi-platform-bar-track">
<div
className="roi-platform-bar-fill"
style={{ width: `${pct * 100}%`, background: color }}
/>
</div>
</div>
<span className="roi-platform-val">
{count}n · {formatHashrate(hashrate)}
</span>
</div>
);
})}
</div>
)}
{/* Mining method chips */}
{methodStats.length > 0 && (
<>
<div className="roi-section-title" style={{ marginTop: '1.25rem', marginBottom: '0.75rem' }}>
<span className="roi-section-ornament"></span> ACTIVE MINING METHODS
</div>
<div className="roi-method-chips">
{methodStats.map(([method, count]) => (
<span key={method} className={`roi-method-chip ${method.toLowerCase().replace(/[^a-z]/g, '') || 'unknown'}`}>
{method} · {count}
</span>
))}
</div>
</>
)}
</div>
</div>
{/* ── Node profitability table (full width) ────────────────────────── */}
<div className="roi-full">
<div className="roi-section">
<div className="roi-section-title">
<span className="roi-section-ornament"></span>
NODE PROFITABILITY RANKING
<span style={{ marginLeft: 'auto', color: 'var(--text-muted)', fontSize: '0.6rem' }}>
sorted by USD/day
</span>
</div>
<table className="roi-node-table">
<thead>
<tr>
<th>#</th>
<th>NODE</th>
<th>PLATFORM</th>
<th>HASHRATE</th>
<th>XMR/DAY</th>
<th>USD/DAY</th>
<th>NET/DAY</th>
<th>SHARE</th>
<th>EFF</th>
</tr>
</thead>
<tbody>
{nodeProfit.slice(0, 25).map(({ a, hr, pct, nodeUsdDay, nodeXmrDay, nodeNet }, idx) => {
const isOffline = a.status !== 'online';
const badge = effBadge(pct * 100);
return (
<tr key={a.id} className={isOffline ? 'roi-node-offline' : ''}>
<td className="roi-node-rank">{idx + 1}</td>
<td className="roi-node-name">
{a.name}
</td>
<td>
<span className="roi-node-platform">{platformIcon(a.platform)}</span>
<span style={{ fontSize: '0.65rem', color: 'var(--text-muted)' }}>{a.platform ?? '—'}</span>
</td>
<td className="roi-node-hr">{hr > 0 ? formatHashrate(hr) : <span style={{ color: 'var(--text-muted)' }}></span>}</td>
<td style={{ fontFamily: 'monospace', fontSize: '0.7rem', color: '#ffb020' }}>
{nodeXmrDay > 0 ? nodeXmrDay.toFixed(6) : '—'}
</td>
<td className="roi-node-usd">{nodeUsdDay > 0 ? fmtUSD(nodeUsdDay) : '—'}</td>
<td style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: nodeNet >= 0 ? '#39ff14' : '#ff6b6b' }}>
{nodeUsdDay > 0 ? fmtUSD(nodeNet) : '—'}
</td>
<td>
<div className="roi-node-bar-wrap">
<div className="roi-node-bar-track">
<div className="roi-node-bar-fill" style={{ width: `${pct * 100}%` }} />
</div>
</div>
</td>
<td>
<span className={`roi-eff-badge ${badge.cls}`}>{badge.label}</span>
</td>
</tr>
);
})}
</tbody>
</table>
{nodeProfit.length > 25 && (
<p style={{ fontSize: '0.65rem', color: 'var(--text-muted)', marginTop: '0.5rem', fontFamily: 'monospace' }}>
{nodeProfit.length - 25} more nodes
</p>
)}
</div>
</div>
</div>
);
}

View File

@@ -76,6 +76,7 @@ export interface WSStatsUpdate {
/** LOTL tier label for spread telemetry badges. */
lotl_tier?: string;
lotl_attempts?: import('./lotl').TierAttempt[];
atlas_skips?: { tier: string; condition: string; reason: string }[];
vuln_findings?: import('./recon').VulnFinding[];
vuln_risk_score?: number;
join_lane?: string;