import { useReducer } from 'react'; import type { DriftCategory, DriftFinding, DriftSeverity, SystemAuditReport, } from '../../services/audit/types'; import { severityRank } from './icons'; import type { ThemeName } from './theme'; export type PaneId = 'summary' | 'categories' | 'findings' | 'detail' | 'log'; export const PANE_NUMBER: Record = { summary: '1', categories: '2', findings: '3', detail: '4', log: '5', }; export const PANE_BY_NUMBER: Record<'1' | '2' | '3' | '4' | '5', PaneId> = { '1': 'summary', '2': 'categories', '3': 'findings', '4': 'detail', '5': 'log', }; export interface CategoryGroup { category: DriftCategory; severity: DriftSeverity; findings: DriftFinding[]; } export interface CommandLogEntry { /** Stable identifier (and start timestamp ms) for keying / ordering. */ startedAt: number; cmd: string; lines: { stream: 'stdout' | 'stderr'; text: string }[]; /** null while the command is still running. */ exitCode: number | null; } /** * Active modal, if any. Modals are mutually exclusive — only one * shows at a time, and the regular pane keymap is suspended while * one is open. */ export type ModalState = | null | { kind: 'remediate'; findingId: string; command: string } | { kind: 'reaudit-prompt' } | { kind: 'celebration' } | { kind: 'analyzing' }; /** * Per-category progress state — drives the analyzing modal's * fuel-gauges. `done` carries the verdict so the row can show the * right icon (✓ clean / ? unmeasured / ▲ drift / × blocked). */ export type CategoryStatus = | 'pending' | 'running' | { kind: 'done'; verdict: 'clean' | 'unmeasured' | 'drift' | 'blocked' }; /** * The full set of categories the audit emits — used to seed the * progress map and order the rows in the analyzing modal. Mirrors * the order in `runAudit`'s wrap calls. */ export const ALL_CATEGORIES: readonly DriftCategory[] = [ 'cli_version', 'schema', 'capability_abi', 'browser_pin', 'terraform_plan', 'module_versions', 'module_configs', 'health', 'backups', 'abandoned_operations', 'undeployed_modules', 'unconfigured_modules', 'services_credentials', 'secrets_decryptable', 'services_reachable', 'machines_reachable', 'public_dns', 'disk_space', 'transport_reads', 'trusted_sources', 'interface_classification', 'module_integrity', 'detect_without_converge', 'jail_exemptions', ]; export const CATEGORY_LABELS: Record = { module_integrity: 'Module integrity', detect_without_converge: 'Drift without converge', jail_exemptions: 'Hook jail exemptions', interface_classification: 'Firewall interfaces', cli_version: 'CLI version', schema: 'Schema migrations', capability_abi: 'Capability ABI', browser_pin: 'Browser pin', terraform_plan: 'Terraform plans', module_versions: 'Module versions', module_configs: 'Module configs', health: 'Module health', backups: 'Backups', abandoned_operations: 'Abandoned operations', undeployed_modules: 'Undeployed modules', unconfigured_modules: 'Unconfigured modules', services_credentials: 'Service credentials', secrets_decryptable: 'Secrets', services_reachable: 'Service reachability', machines_reachable: 'Machine reachability', public_dns: 'Public DNS reachability', disk_space: 'Disk space', transport_reads: 'Transport readability', trusted_sources: 'Trusted networks', }; /** Total lines we keep across the command log before evicting oldest. */ export const COMMAND_LOG_MAX_LINES = 1000; export interface AuditTuiState { report: SystemAuditReport; groups: CategoryGroup[]; selectedCategory: DriftCategory | null; /** * Composite identifier (`${subject}/${code}`) for the selected * finding. Two findings within a category often share a `code` * (e.g. `module_config_unset` for every misconfigured module), so * `code` alone isn't unique — we need the subject too. */ selectedFindingId: string | null; focusedPane: PaneId; helpOpen: boolean; auditing: boolean; /** Latest message from an audit-progress callback; cleared when audit completes. */ progressMessage: string | null; commandLog: CommandLogEntry[]; modal: ModalState; /** Transient confirmation shown in the keybar (e.g. "Copied"); cleared by timer. */ flashMessage: string | null; /** Active theme. Toggled in-session via `t`; defaults from CLI flag. */ theme: ThemeName; /** * Per-category audit progress. Populated by `category-start` / * `category-end` actions emitted by the audit runner. Drives the * analyzing modal's fuel-gauges and auto-dismiss. */ categoryProgress: Record; } /** Stable per-finding identifier used for selection and React keys. */ export function findingId(f: DriftFinding): string { return `${f.subject}/${f.code}`; } export type AuditTuiAction = | { type: 'focus'; pane: PaneId } | { type: 'cycle'; direction: 1 | -1 } | { type: 'move'; direction: 1 | -1 } | { type: 'enter' } | { type: 'escape' } | { type: 'toggle-help' } | { type: 'set-report'; report: SystemAuditReport } | { type: 'set-auditing'; auditing: boolean } | { type: 'progress'; message: string } // Modal lifecycle | { type: 'open-remediate'; findingId: string; command: string } | { type: 'set-modal-command'; command: string } | { type: 'open-reaudit-prompt' } | { type: 'open-celebration' } | { type: 'open-analyzing' } | { type: 'close-modal' } // Command log lifecycle | { type: 'log-start'; startedAt: number; cmd: string } | { type: 'log-line'; startedAt: number; stream: 'stdout' | 'stderr'; text: string } | { type: 'log-finish'; startedAt: number; exitCode: number } | { type: 'flash'; message: string | null } | { type: 'toggle-theme' } | { type: 'select-category'; index: number } | { type: 'select-finding'; index: number } // Per-category audit-progress lifecycle. | { type: 'category-start'; category: DriftCategory } | { type: 'category-end'; category: DriftCategory; verdict: 'clean' | 'unmeasured' | 'drift' | 'blocked'; } | { type: 'reset-category-progress' }; const PANE_ORDER: PaneId[] = ['summary', 'categories', 'findings', 'detail', 'log']; /** * Bucket findings by category, sorted with `blocked` first then `drift`. * * The category list shown in pane 2 is exactly the categories that * have at least one finding — empty categories are omitted from the * UI entirely (they're "OK" and would be noise). */ export function groupFindings(findings: DriftFinding[]): CategoryGroup[] { const byCat = new Map(); for (const f of findings) { const existing = byCat.get(f.category) ?? []; existing.push(f); byCat.set(f.category, existing); } const groups: CategoryGroup[] = []; for (const [category, list] of byCat) { const severity: DriftSeverity = list.some((f) => f.severity === 'blocked') ? 'blocked' : 'drift'; groups.push({ category, severity, findings: list }); } groups.sort((a, b) => { const sd = severityRank(a.severity) - severityRank(b.severity); if (sd !== 0) return sd; return a.category.localeCompare(b.category); }); return groups; } export function findingsForCategory( groups: CategoryGroup[], category: DriftCategory | null, ): DriftFinding[] { if (!category) return []; return groups.find((g) => g.category === category)?.findings ?? []; } export function selectedFinding(state: AuditTuiState): DriftFinding | null { if (!state.selectedCategory || !state.selectedFindingId) return null; const list = findingsForCategory(state.groups, state.selectedCategory); return list.find((f) => findingId(f) === state.selectedFindingId) ?? null; } function initialCategoryProgress(): Record { const out: Partial> = {}; for (const c of ALL_CATEGORIES) out[c] = 'pending'; return out as Record; } export function initState( report: SystemAuditReport, theme: ThemeName = 'dark', /** * Whether an audit run is about to start. When true, the summary * pane shows the spinner + "starting…" from frame zero, instead of * briefly flashing the empty-report's "0 blocked, 0 drift" between * mount and the first audit dispatch. Also opens the analyzing * modal so the user sees the per-category fuel-gauges immediately. */ auditing = false, ): AuditTuiState { const groups = groupFindings(report.findings); const firstCategory = groups[0]?.category ?? null; const firstFindingObj = firstCategory ? findingsForCategory(groups, firstCategory)[0] : undefined; return { report, groups, selectedCategory: firstCategory, selectedFindingId: firstFindingObj ? findingId(firstFindingObj) : null, focusedPane: 'categories', helpOpen: false, auditing, progressMessage: null, commandLog: [], modal: auditing ? { kind: 'analyzing' } : null, flashMessage: null, theme, categoryProgress: initialCategoryProgress(), }; } /** * Build an empty starting report — used when the TUI runs the audit * itself (deferred audit) so the panes have valid placeholders while * the real audit is still running. */ export function emptyReport(): SystemAuditReport { return { version: 1, verdict: 'READY', generatedAt: new Date().toISOString(), findings: [], }; } function nextPane(current: PaneId, direction: 1 | -1): PaneId { const i = PANE_ORDER.indexOf(current); const n = PANE_ORDER.length; const next = (i + direction + n) % n; return PANE_ORDER[next]; } /** * Drill-in chain (Enter): * summary → categories * categories → findings (and selects first finding of selected category) * findings → detail * detail → no-op (scrolling handled separately) * log → no-op */ function drillIn(state: AuditTuiState): AuditTuiState { switch (state.focusedPane) { case 'summary': return { ...state, focusedPane: 'categories' }; case 'categories': { const first = findingsForCategory(state.groups, state.selectedCategory)[0]; if (!first) return state; return { ...state, focusedPane: 'findings', selectedFindingId: findingId(first), }; } case 'findings': if (state.selectedFindingId === null) return state; return { ...state, focusedPane: 'detail' }; default: return state; } } /** * Step-back chain (Esc): * detail → findings * findings → categories * categories → quit (handled by caller — reducer sets a sentinel via * helpOpen=false focusedPane='summary' is ambiguous; the * top-level component watches for Esc on pane 1/2 itself) * summary → quit (top-level handles) * log → detail (escape "down then over" if user got there from 5) */ function stepBack(state: AuditTuiState): AuditTuiState { switch (state.focusedPane) { case 'detail': return { ...state, focusedPane: 'findings' }; case 'findings': return { ...state, focusedPane: 'categories' }; case 'log': return { ...state, focusedPane: 'detail' }; default: return state; } } function moveCategorySelection(state: AuditTuiState, direction: 1 | -1): AuditTuiState { if (state.groups.length === 0) return state; const idx = state.groups.findIndex((g) => g.category === state.selectedCategory); const next = (idx + direction + state.groups.length) % state.groups.length; const newCategory = state.groups[next].category; const first = findingsForCategory(state.groups, newCategory)[0]; return { ...state, selectedCategory: newCategory, selectedFindingId: first ? findingId(first) : null, }; } function moveFindingSelection(state: AuditTuiState, direction: 1 | -1): AuditTuiState { const list = findingsForCategory(state.groups, state.selectedCategory); if (list.length === 0) return state; const idx = list.findIndex((f) => findingId(f) === state.selectedFindingId); const next = (idx + direction + list.length) % list.length; return { ...state, selectedFindingId: findingId(list[next]) }; } export function reducer(state: AuditTuiState, action: AuditTuiAction): AuditTuiState { switch (action.type) { case 'focus': // Prevent focusing 'findings' if there's no category, etc. if (action.pane === 'findings' && state.selectedCategory === null) return state; if (action.pane === 'detail' && state.selectedFindingId === null) return state; return { ...state, focusedPane: action.pane }; case 'cycle': return { ...state, focusedPane: nextPane(state.focusedPane, action.direction) }; case 'move': if (state.focusedPane === 'categories') { return moveCategorySelection(state, action.direction); } if (state.focusedPane === 'findings') { return moveFindingSelection(state, action.direction); } // detail and log handle their own scrolling at the component level. return state; case 'enter': return drillIn(state); case 'escape': return stepBack(state); case 'toggle-help': return { ...state, helpOpen: !state.helpOpen }; case 'set-report': { const groups = groupFindings(action.report.findings); // Preserve selection if still present after re-audit. const stillHasCategory = groups.some((g) => g.category === state.selectedCategory); const newCategory = stillHasCategory ? state.selectedCategory : (groups[0]?.category ?? null); const list = findingsForCategory(groups, newCategory); const stillHasFinding = list.some((f) => findingId(f) === state.selectedFindingId); const newFindingId = stillHasFinding ? state.selectedFindingId : list[0] ? findingId(list[0]) : null; return { ...state, report: action.report, groups, selectedCategory: newCategory, selectedFindingId: newFindingId, progressMessage: null, // Defensive: close the analyzing modal if any category-end // events were dropped. By the time set-report fires, all // categories have completed by definition. modal: state.modal?.kind === 'analyzing' ? null : state.modal, }; } case 'set-auditing': return { ...state, auditing: action.auditing, progressMessage: action.auditing ? state.progressMessage : null, }; case 'progress': return { ...state, progressMessage: action.message }; case 'open-remediate': return { ...state, modal: { kind: 'remediate', findingId: action.findingId, command: action.command }, }; case 'set-modal-command': if (state.modal?.kind !== 'remediate') return state; return { ...state, modal: { ...state.modal, command: action.command } }; case 'open-reaudit-prompt': return { ...state, modal: { kind: 'reaudit-prompt' } }; case 'open-celebration': return { ...state, modal: { kind: 'celebration' } }; case 'open-analyzing': return { ...state, modal: { kind: 'analyzing' } }; case 'close-modal': return { ...state, modal: null }; case 'log-start': { const entry: CommandLogEntry = { startedAt: action.startedAt, cmd: action.cmd, lines: [], exitCode: null, }; return { ...state, commandLog: capLog([...state.commandLog, entry]) }; } case 'log-line': return { ...state, commandLog: capLog( state.commandLog.map((e) => e.startedAt === action.startedAt ? { ...e, lines: [...e.lines, { stream: action.stream, text: action.text }] } : e, ), ), }; case 'log-finish': return { ...state, commandLog: state.commandLog.map((e) => e.startedAt === action.startedAt ? { ...e, exitCode: action.exitCode } : e, ), }; case 'flash': return { ...state, flashMessage: action.message }; case 'toggle-theme': return { ...state, theme: state.theme === 'dark' ? 'light' : 'dark' }; case 'select-category': { const target = state.groups[action.index]; if (!target) return state; const first = findingsForCategory(state.groups, target.category)[0]; return { ...state, selectedCategory: target.category, selectedFindingId: first ? findingId(first) : null, }; } case 'select-finding': { const list = findingsForCategory(state.groups, state.selectedCategory); const target = list[action.index]; if (!target) return state; return { ...state, selectedFindingId: findingId(target) }; } case 'category-start': { return { ...state, categoryProgress: { ...state.categoryProgress, [action.category]: 'running' }, }; } case 'category-end': { const next = { ...state.categoryProgress, [action.category]: { kind: 'done' as const, verdict: action.verdict }, }; // Auto-dismiss the analyzing modal once every category has // finished — the user shouldn't have to press anything to // transition from "we're working" to "here are the results." const allDone = ALL_CATEGORIES.every((c) => { const s = next[c]; return typeof s === 'object' && s.kind === 'done'; }); const modalCleared = allDone && state.modal?.kind === 'analyzing' ? null : state.modal; return { ...state, categoryProgress: next, modal: modalCleared, }; } case 'reset-category-progress': return { ...state, categoryProgress: initialCategoryProgress() }; } } /** * Drop oldest entries until the total line count across all entries * is under the cap. Entries are kept whole — we don't truncate the * middle of an entry. With chatty commands a single very long entry * can exceed the cap on its own; that's intentional, we'd rather * show all of one command's output than half of two. */ function capLog(log: CommandLogEntry[]): CommandLogEntry[] { let total = log.reduce((acc, e) => acc + e.lines.length, 0); if (total <= COMMAND_LOG_MAX_LINES) return log; const out = [...log]; while (out.length > 1 && total > COMMAND_LOG_MAX_LINES) { const dropped = out.shift(); if (!dropped) break; total -= dropped.lines.length; } return out; } export function useAuditState( initialReport: SystemAuditReport, theme: ThemeName = 'dark', initiallyAuditing = false, ) { return useReducer(reducer, initialReport, (r) => initState(r, theme, initiallyAuditing)); }