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)
|
||||
|
||||
- **[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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: (
|
||||
|
||||
@@ -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">
|
||||
|
||||
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: /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,
|
||||
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,
|
||||
|
||||
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;
|
||||
/** 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;
|
||||
|
||||
Reference in New Issue
Block a user