Add LOTL Timeline page for live tier progression visualization.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled

Operators get a dedicated Onion view with 14-tier stepper, fleet progress chips, and AI decision overlay when fleet AI control is enabled.
This commit is contained in:
AetherForge
2026-06-07 02:13:15 -07:00
parent f27cec887a
commit 34afa28f81
14 changed files with 939 additions and 2 deletions

View File

@@ -35,12 +35,14 @@ function operatorDeckId(pathname: string): string {
if (path.startsWith('/builds')) return 'builds';
if (path.startsWith('/settings')) return 'settings';
if (path.startsWith('/pathtracer')) return 'pathtracer';
if (path.startsWith('/lotl-timeline') || path.startsWith('/onion')) return 'lotl-timeline';
return 'dashboard';
}
const NAV = [
{ to: '/dashboard', label: 'Command Deck', icon: 'deck' },
{ to: '/crucible', label: 'Crucible', icon: 'crucible' },
{ to: '/lotl-timeline', label: 'Onion', icon: 'onion' },
{ to: '/pathtracer', label: 'Path Tracer', icon: 'trace' },
{ to: '/forge', label: 'Forge', icon: 'forge' },
{ to: '/mission-deck', label: 'Mission Deck', icon: 'mission', glow: true },
@@ -103,6 +105,14 @@ function NavIcon({ type }: { type: string }) {
<path d="M10 12l1.5 2L14 11" />
</svg>
);
case 'onion':
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="13" r="6" />
<circle cx="12" cy="13" r="3" strokeOpacity="0.45" />
<path d="M12 4v3M12 19v3M4 13h3M17 13h3" strokeOpacity="0.5" />
</svg>
);
case 'ember':
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">

View File

@@ -0,0 +1,46 @@
import type { Agent } from '../../types';
import { buildLotlTimelineModel, lotlTimelineProgressLabel } from '../../help/lotlTimeline';
import type { TierAttempt } from '../../types/lotl';
import './LotlTimeline.css';
interface Props {
agents: Agent[];
selectedId?: string;
tierOrder: string[];
onSelect: (agentId: string) => void;
}
function agentAttempts(agent: Agent): TierAttempt[] {
return agent.lotl_attempts ?? [];
}
export default function LotlFleetOverview({ agents, selectedId, tierOrder, onSelect }: Props) {
const online = agents.filter((a) => a.status === 'online');
if (online.length === 0) return null;
return (
<div className="lotl-fleet-overview" aria-label="Fleet tier progress">
<div className="lotl-fleet-overview-title">FLEET ONION PROGRESS</div>
{online.map((agent) => {
const model = buildLotlTimelineModel(agent, tierOrder, agentAttempts(agent));
const pct = model.total > 0 ? Math.round((model.succeeded / model.total) * 100) : 0;
const selected = agent.id === selectedId;
return (
<button
key={agent.id}
type="button"
className={`lotl-fleet-chip${selected ? ' lotl-fleet-chip--selected' : ''}`}
onClick={() => onSelect(agent.id)}
title={`${agent.name}${lotlTimelineProgressLabel(model)} tiers`}
>
<span className="lotl-fleet-chip-name">{agent.name}</span>
<div className="lotl-fleet-chip-bar" aria-hidden>
<div className="lotl-fleet-chip-fill" style={{ width: `${pct}%` }} />
</div>
<span className="lotl-fleet-chip-meta">{lotlTimelineProgressLabel(model)} tiers</span>
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,58 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render, screen, fireEvent } from '@testing-library/react';
import LotlTierTimeline from './LotlTierTimeline';
import { buildLotlTimelineModel, resolveLotlTierOrder } from '../../help/lotlTimeline';
import { mockAgent } from '../../test/fixtures';
describe('LotlTierTimeline', () => {
afterEach(() => cleanup());
it('renders 14 tiers from fixture attempts', () => {
const order = resolveLotlTierOrder();
expect(order).toHaveLength(14);
const agent = mockAgent({
status: 'online',
lotl_tier: 'wsl',
lotl_attempts: [
{ tier: 'vuln_recon', ok: true, duration_ms: 800, phase: 'recon' },
{ tier: 'docker', ok: false, error: 'no daemon', duration_ms: 1200, phase: 'deploy' },
{ tier: 'wsl', ok: false, error: 'distro missing', duration_ms: 900, phase: 'deploy' },
],
});
const model = buildLotlTimelineModel(agent, order, agent.lotl_attempts ?? []);
expect(model.tiers).toHaveLength(14);
expect(model.succeeded).toBe(1);
expect(model.tiers[0].state).toBe('success');
expect(model.tiers[1].state).toBe('failed');
expect(model.tiers[2].state).toBe('trying');
render(<LotlTierTimeline model={model} agentName="Test Miner" />);
expect(screen.getByText('ONION TIER CHAIN')).toBeInTheDocument();
expect(screen.getByText('1/14 tiers succeeded')).toBeInTheDocument();
expect(screen.getAllByRole('listitem')).toHaveLength(14);
expect(screen.getByText('Vuln Recon')).toBeInTheDocument();
expect(screen.getByText('GPO')).toBeInTheDocument();
});
it('expands attempt detail on tier click', () => {
const order = resolveLotlTierOrder();
const agent = mockAgent({
lotl_attempts: [
{ tier: 'docker', ok: false, error: 'AV blocked', duration_ms: 500, phase: 'deploy' },
],
});
const model = buildLotlTimelineModel(agent, order, agent.lotl_attempts ?? []);
render(<LotlTierTimeline model={model} agentName="Node A" />);
fireEvent.click(screen.getByText('Docker'));
expect(screen.getByText('AV blocked')).toBeInTheDocument();
expect(screen.getByText('deploy')).toBeInTheDocument();
expect(screen.getByText('500ms')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,109 @@
import { useMemo, useState } from 'react';
import type { LotlTimelineModel, LotlTimelineTierRow } from '../../help/lotlTimeline';
import { formatDurationMs } from '../../types/lotl';
import './LotlTimeline.css';
import '../Fleet/LotlVisuals.css';
interface Props {
model: LotlTimelineModel;
agentName: string;
className?: string;
}
function stateGlyph(state: LotlTimelineTierRow['state']): string {
switch (state) {
case 'success':
return '✓';
case 'failed':
return '✗';
case 'skipped':
return '—';
case 'trying':
return '◉';
default:
return '·';
}
}
function TierDetail({ row }: { row: LotlTimelineTierRow }) {
const { attempt } = row;
return (
<div className="lotl-tier-detail" role="region" aria-label={`${row.label} attempt detail`}>
<div className="lotl-tier-detail-title">{row.label.toUpperCase()}</div>
{row.hint && <div className="lotl-tier-detail-row">{row.hint}</div>}
{attempt ? (
<>
<div className="lotl-tier-detail-row">
<span className={`lotl-attempt-icon ${attempt.ok ? 'ok' : 'fail'}`}>
{attempt.ok ? '✓' : '✗'}
</span>
<span>{attempt.ok ? 'Succeeded' : 'Failed'}</span>
{attempt.duration_ms !== undefined && (
<span className="lotl-attempt-dur">{formatDurationMs(attempt.duration_ms)}</span>
)}
{attempt.phase && <span className="lotl-tier-detail-tag">{attempt.phase}</span>}
</div>
{!attempt.ok && attempt.error && (
<div className="lotl-tier-detail-err">{attempt.error}</div>
)}
</>
) : (
<div className="lotl-tier-detail-row">
{row.state === 'skipped' ? 'Tier skipped by policy' : 'No attempt recorded yet'}
</div>
)}
</div>
);
}
export default function LotlTierTimeline({ model, agentName, className = '' }: Props) {
const [expandedTier, setExpandedTier] = useState<string | null>(null);
const expandedRow = useMemo(
() => model.tiers.find((t) => t.tier === expandedTier),
[model.tiers, expandedTier],
);
const toggleTier = (tier: string) => {
setExpandedTier((prev) => (prev === tier ? null : tier));
};
return (
<section className={`lotl-tier-timeline ${className}`.trim()} aria-label="LOTL tier progression">
<div className="lotl-tier-timeline-header">
<div>
<div className="lotl-tier-timeline-title">ONION TIER CHAIN</div>
<div className="lotl-tier-timeline-agent">{agentName}</div>
</div>
<div className="lotl-tier-timeline-progress">
{model.succeeded}/{model.total} tiers succeeded
{model.tryingTier && (
<span style={{ marginLeft: '0.5rem', color: '#ffb070' }}>
· trying {model.tryingTier.replace(/_/g, ' ')}
</span>
)}
</div>
</div>
<div className="lotl-tier-track" role="list">
{model.tiers.map((row) => (
<button
key={row.tier}
type="button"
role="listitem"
className={`lotl-tier-step lotl-tier-step--${row.state}${expandedTier === row.tier ? ' lotl-tier-step--expanded' : ''}`}
onClick={() => toggleTier(row.tier)}
title={row.hint || row.label}
aria-expanded={expandedTier === row.tier}
aria-current={row.state === 'trying' ? 'step' : undefined}
>
<span className="lotl-tier-node">{stateGlyph(row.state)}</span>
<span className="lotl-tier-label">{row.label}</span>
</button>
))}
</div>
{expandedRow && <TierDetail row={expandedRow} />}
</section>
);
}

View File

@@ -0,0 +1,352 @@
/* LOTL Onion Timeline — ember-accent progression stepper */
.lotl-timeline-page {
max-width: 1400px;
}
.lotl-timeline-page .page-header h1 {
background: linear-gradient(90deg, #e8c872 0%, #ff8c3a 45%, #e85d4a 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
letter-spacing: 0.04em;
}
.lotl-fleet-overview {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1.25rem;
padding: 0.65rem 0.75rem;
border-radius: var(--deck-card-radius, 6px);
border: 1px solid rgba(232, 140, 58, 0.22);
background: rgba(8, 6, 4, 0.55);
}
.lotl-fleet-overview-title {
width: 100%;
font-family: var(--font-tech);
font-size: 0.68rem;
letter-spacing: 0.14em;
color: rgba(232, 180, 100, 0.85);
margin-bottom: 0.15rem;
}
.lotl-fleet-chip {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 120px;
max-width: 160px;
flex: 1 1 120px;
padding: 0.45rem 0.55rem;
border-radius: 5px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(0, 0, 0, 0.35);
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.2s;
text-align: left;
}
.lotl-fleet-chip:hover {
border-color: rgba(255, 140, 58, 0.35);
}
.lotl-fleet-chip--selected {
border-color: rgba(255, 140, 58, 0.55);
box-shadow: 0 0 12px rgba(232, 93, 74, 0.18);
}
.lotl-fleet-chip-name {
font-size: 0.72rem;
font-weight: 600;
color: #eee;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.lotl-fleet-chip-bar {
height: 4px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.08);
overflow: hidden;
}
.lotl-fleet-chip-fill {
height: 100%;
border-radius: 2px;
background: linear-gradient(90deg, #c4ad5a, #ff8c3a);
transition: width 0.35s ease;
}
.lotl-fleet-chip-meta {
font-family: var(--font-tech);
font-size: 0.6rem;
color: var(--text-muted);
letter-spacing: 0.06em;
}
.lotl-tier-timeline {
padding: 1rem 1.1rem 1.25rem;
border-radius: var(--deck-card-radius, 6px);
border: 1px solid rgba(232, 140, 58, 0.2);
background: linear-gradient(165deg, rgba(14, 10, 8, 0.92) 0%, rgba(6, 5, 8, 0.96) 100%);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 180, 80, 0.06);
}
.lotl-tier-timeline-header {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 1rem;
}
.lotl-tier-timeline-title {
font-family: var(--font-tech);
font-size: 0.72rem;
letter-spacing: 0.14em;
color: rgba(232, 180, 100, 0.9);
}
.lotl-tier-timeline-agent {
font-size: 0.8rem;
color: var(--text-muted);
}
.lotl-tier-timeline-progress {
font-family: var(--font-tech);
font-size: 0.75rem;
color: #ffaa66;
}
.lotl-tier-track {
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
gap: 0;
padding: 0.5rem 0 0.75rem;
scrollbar-width: thin;
}
.lotl-tier-step {
display: flex;
flex-direction: column;
align-items: center;
flex: 1 1 0;
min-width: 72px;
max-width: 96px;
position: relative;
border: none;
background: transparent;
cursor: pointer;
padding: 0.25rem 0.15rem;
color: inherit;
font: inherit;
}
.lotl-tier-step:not(:last-child)::after {
content: '';
position: absolute;
top: 14px;
left: calc(50% + 14px);
width: calc(100% - 28px);
height: 2px;
background: rgba(255, 255, 255, 0.08);
z-index: 0;
}
.lotl-tier-step--success:not(:last-child)::after,
.lotl-tier-step--trying:not(:last-child)::after {
background: linear-gradient(90deg, rgba(255, 140, 58, 0.5), rgba(255, 255, 255, 0.08));
}
.lotl-tier-node {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-tech);
font-size: 0.62rem;
font-weight: 700;
border: 2px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.45);
position: relative;
z-index: 1;
transition: border-color 0.2s, box-shadow 0.25s;
}
.lotl-tier-step--pending .lotl-tier-node {
color: var(--text-muted);
border-color: rgba(255, 255, 255, 0.1);
}
.lotl-tier-step--skipped .lotl-tier-node {
color: rgba(180, 180, 190, 0.5);
border-color: rgba(255, 255, 255, 0.06);
opacity: 0.55;
}
.lotl-tier-step--success .lotl-tier-node {
color: #7dffaa;
border-color: rgba(100, 220, 140, 0.45);
background: rgba(40, 80, 50, 0.35);
}
.lotl-tier-step--failed .lotl-tier-node {
color: #ff8866;
border-color: rgba(255, 100, 60, 0.45);
background: rgba(80, 30, 20, 0.35);
}
.lotl-tier-step--trying .lotl-tier-node {
color: #ffb84d;
border-color: rgba(255, 160, 60, 0.7);
background: rgba(60, 35, 15, 0.5);
box-shadow:
0 0 14px rgba(255, 120, 40, 0.45),
0 0 28px rgba(232, 93, 74, 0.2);
animation: lotl-tier-pulse 1.8s ease-in-out infinite;
}
@keyframes lotl-tier-pulse {
0%, 100% {
box-shadow:
0 0 10px rgba(255, 120, 40, 0.35),
0 0 20px rgba(232, 93, 74, 0.15);
}
50% {
box-shadow:
0 0 18px rgba(255, 140, 58, 0.55),
0 0 36px rgba(232, 93, 74, 0.28);
}
}
.lotl-tier-label {
margin-top: 0.35rem;
font-size: 0.58rem;
text-align: center;
line-height: 1.2;
color: var(--text-muted);
max-width: 100%;
word-break: break-word;
}
.lotl-tier-step--trying .lotl-tier-label,
.lotl-tier-step--success .lotl-tier-label {
color: rgba(240, 220, 200, 0.9);
}
.lotl-tier-detail {
margin-top: 0.75rem;
padding: 0.65rem 0.85rem;
border-left: 2px solid rgba(255, 140, 58, 0.4);
background: rgba(0, 0, 0, 0.35);
border-radius: 0 4px 4px 0;
font-family: var(--font-tech);
font-size: 0.74rem;
}
.lotl-tier-detail-title {
color: #ffb070;
font-weight: 700;
letter-spacing: 0.08em;
font-size: 0.68rem;
margin-bottom: 0.35rem;
}
.lotl-tier-detail-row {
display: flex;
flex-wrap: wrap;
gap: 0.35rem 0.75rem;
margin-bottom: 0.25rem;
color: #ddd;
}
.lotl-tier-detail-tag {
font-size: 0.62rem;
padding: 1px 6px;
border-radius: 3px;
background: rgba(255, 140, 58, 0.12);
border: 1px solid rgba(255, 140, 58, 0.25);
color: #ffaa66;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.lotl-tier-detail-err {
color: #ffaa88;
font-size: 0.68rem;
margin-top: 0.25rem;
word-break: break-word;
}
.lotl-ai-decision {
margin-top: 0.85rem;
padding: 0.55rem 0.75rem;
border-radius: 4px;
border: 1px solid rgba(0, 232, 245, 0.2);
background: rgba(0, 40, 50, 0.25);
font-size: 0.72rem;
}
.lotl-ai-decision-label {
font-family: var(--font-tech);
font-size: 0.62rem;
letter-spacing: 0.1em;
color: var(--neon-cyan);
margin-bottom: 0.25rem;
}
.lotl-timeline-links {
margin-top: 0.85rem;
font-size: 0.72rem;
color: var(--text-muted);
}
.lotl-timeline-links a {
color: rgba(255, 170, 100, 0.9);
text-decoration: none;
}
.lotl-timeline-links a:hover {
text-decoration: underline;
}
.lotl-timeline-empty {
padding: 2rem;
text-align: center;
color: var(--text-muted);
font-size: 0.85rem;
}
@media (max-width: 720px) {
.lotl-tier-track {
flex-direction: column;
align-items: stretch;
overflow-x: visible;
}
.lotl-tier-step {
flex-direction: row;
align-items: center;
gap: 0.65rem;
min-width: 100%;
max-width: none;
padding: 0.35rem 0;
}
.lotl-tier-step:not(:last-child)::after {
display: none;
}
.lotl-tier-label {
margin-top: 0;
text-align: left;
flex: 1;
}
}

View File

@@ -923,5 +923,6 @@ describe('Layout', () => {
});
expect(screen.getByRole('link', { name: /Command Deck/i })).toBeInTheDocument();
expect(screen.getByRole('link', { name: /Crucible/i })).toBeInTheDocument();
expect(screen.getByRole('link', { name: /Onion/i })).toBeInTheDocument();
});
});