fix: audit console navigation — use arrow keys like main console

- Sidebar open: arrows navigate sessions, Enter selects, Escape closes
- Sidebar closed: arrows navigate timeline, Escape reopens sidebar
- Fix crash on `data.events.reverse()` when API returns non-array
- Fix blinking from useCallback re-creating polling intervals (use useRef)
- Remove 's' key session cycling — use standard arrow+Enter pattern

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-03-04 00:00:59 +00:00
parent 5d859ca7d8
commit 75c44e4ba1

View File

@@ -1,11 +1,12 @@
/** /**
* AuditConsoleApp — TUI for browsing audit events from mcpd. * AuditConsoleApp — TUI for browsing audit events from mcpd.
* *
* Shows sessions in a sidebar and events in a timeline. * Navigation follows the same patterns as the main unified console:
* Polls mcpd periodically for new data. * - Sidebar open: arrows navigate sessions, Enter selects, Escape closes
* - Sidebar closed: arrows navigate timeline, Escape reopens sidebar
*/ */
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { render, Box, Text, useInput, useApp, useStdout } from 'ink'; import { render, Box, Text, useInput, useApp, useStdout } from 'ink';
import type { AuditSession, AuditEvent, AuditConsoleState } from './audit-types.js'; import type { AuditSession, AuditEvent, AuditConsoleState } from './audit-types.js';
import { EVENT_KIND_COLORS, EVENT_KIND_LABELS } from './audit-types.js'; import { EVENT_KIND_COLORS, EVENT_KIND_LABELS } from './audit-types.js';
@@ -208,42 +209,58 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) {
kindFilter: null, kindFilter: null,
}); });
// Fetch sessions // Use refs for polling to avoid re-creating intervals on every state change
const stateRef = useRef(state);
stateRef.current = state;
// Fetch sessions (stable — no state deps)
const fetchSessions = useCallback(async () => { const fetchSessions = useCallback(async () => {
try { try {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (state.projectFilter) params.set('projectName', state.projectFilter); const pf = stateRef.current.projectFilter;
if (pf) params.set('projectName', pf);
params.set('limit', '50'); params.set('limit', '50');
const url = `${mcpdUrl}/api/v1/audit/sessions?${params.toString()}`; const url = `${mcpdUrl}/api/v1/audit/sessions?${params.toString()}`;
const data = await fetchJson<{ sessions: AuditSession[]; total: number }>(url, token); const data = await fetchJson<{ sessions?: AuditSession[]; total?: number }>(url, token);
setState((prev) => ({ ...prev, sessions: data.sessions, phase: 'ready' })); if (data.sessions && Array.isArray(data.sessions)) {
} catch (err) { setState((prev) => ({ ...prev, sessions: data.sessions!, phase: 'ready' }));
setState((prev) => ({ ...prev, phase: 'error', error: err instanceof Error ? err.message : String(err) }));
} }
}, [mcpdUrl, token, state.projectFilter]); } catch (err) {
setState((prev) => {
// Only show error if we haven't loaded anything yet
if (prev.phase === 'loading') {
return { ...prev, phase: 'error', error: err instanceof Error ? err.message : String(err) };
}
return prev; // Keep existing data on transient errors
});
}
}, [mcpdUrl, token]);
// Fetch events // Fetch events (stable — no state deps)
const fetchEvents = useCallback(async () => { const fetchEvents = useCallback(async () => {
try { try {
const s = stateRef.current;
const params = new URLSearchParams(); const params = new URLSearchParams();
const selectedSession = state.selectedSessionIdx >= 0 ? state.sessions[state.selectedSessionIdx] : undefined; const selectedSession = s.selectedSessionIdx >= 0 ? s.sessions[s.selectedSessionIdx] : undefined;
if (selectedSession) { if (selectedSession) {
params.set('sessionId', selectedSession.sessionId); params.set('sessionId', selectedSession.sessionId);
} else if (state.projectFilter) { } else if (s.projectFilter) {
params.set('projectName', state.projectFilter); params.set('projectName', s.projectFilter);
} }
if (state.kindFilter) params.set('eventKind', state.kindFilter); if (s.kindFilter) params.set('eventKind', s.kindFilter);
params.set('limit', String(MAX_EVENTS)); params.set('limit', String(MAX_EVENTS));
const url = `${mcpdUrl}/api/v1/audit/events?${params.toString()}`; const url = `${mcpdUrl}/api/v1/audit/events?${params.toString()}`;
const data = await fetchJson<{ events: AuditEvent[]; total: number }>(url, token); const data = await fetchJson<{ events?: AuditEvent[]; total?: number }>(url, token);
if (data.events && Array.isArray(data.events)) {
// API returns newest first — reverse for timeline display // API returns newest first — reverse for timeline display
setState((prev) => ({ ...prev, events: data.events.reverse(), totalEvents: data.total })); setState((prev) => ({ ...prev, events: data.events!.reverse(), totalEvents: data.total ?? data.events!.length }));
}
} catch { } catch {
// Non-fatal — keep existing events // Non-fatal — keep existing events
} }
}, [mcpdUrl, token, state.selectedSessionIdx, state.sessions, state.projectFilter, state.kindFilter]); }, [mcpdUrl, token]);
// Initial load + polling // Initial load + polling (single stable interval)
useEffect(() => { useEffect(() => {
void fetchSessions(); void fetchSessions();
void fetchEvents(); void fetchEvents();
@@ -256,21 +273,17 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) {
// Keyboard input // Keyboard input
useInput((input, key) => { useInput((input, key) => {
const s = stateRef.current;
// Quit // Quit
if (input === 'q') { if (input === 'q') {
exit(); exit();
return; return;
} }
// Toggle sidebar // ── Detail view navigation ──
if (s.detailEvent) {
if (key.escape) { if (key.escape) {
setState((prev) => ({ ...prev, showSidebar: !prev.showSidebar }));
return;
}
// Detail view navigation
if (state.detailEvent) {
if (input === 'q' || key.escape) {
setState((prev) => ({ ...prev, detailEvent: null, detailScrollOffset: 0 })); setState((prev) => ({ ...prev, detailEvent: null, detailScrollOffset: 0 }));
return; return;
} }
@@ -295,22 +308,34 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) {
return; return;
} }
// Sidebar mode: Tab toggles focus between sidebar and timeline // ── Sidebar navigation (arrows = sessions, Enter = select, Escape = close) ──
if (key.tab && state.showSidebar) { if (s.showSidebar) {
// Tab cycles: session list focus ↔ timeline focus if (key.downArrow) {
// Use selectedSessionIdx >= -2 as "sidebar focused" indicator setState((prev) => ({
// Actually, let's use a simpler approach: Shift+Tab or 's' focuses sidebar ...prev,
selectedSessionIdx: Math.min(prev.sessions.length - 1, prev.selectedSessionIdx + 1),
focusedEventIdx: -1,
}));
// Re-fetch events for the newly selected session
void fetchEvents();
return; return;
} }
if (key.upArrow) {
// Session navigation (when sidebar visible) setState((prev) => ({
if (state.showSidebar && (input === 'S' || input === 's')) { ...prev,
// s/S cycles through sessions selectedSessionIdx: Math.max(-1, prev.selectedSessionIdx - 1),
setState((prev) => { focusedEventIdx: -1,
const max = prev.sessions.length - 1; }));
const next = prev.selectedSessionIdx >= max ? -1 : prev.selectedSessionIdx + 1; void fetchEvents();
return { ...prev, selectedSessionIdx: next, focusedEventIdx: -1 }; return;
}); }
if (key.return) {
// Enter closes sidebar, keeping the selected session
setState((prev) => ({ ...prev, showSidebar: false, focusedEventIdx: -1 }));
return;
}
if (key.escape) {
setState((prev) => ({ ...prev, showSidebar: false }));
return; return;
} }
@@ -320,8 +345,20 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) {
setState((prev) => { setState((prev) => {
const currentIdx = kinds.indexOf(prev.kindFilter); const currentIdx = kinds.indexOf(prev.kindFilter);
const nextIdx = (currentIdx + 1) % kinds.length; const nextIdx = (currentIdx + 1) % kinds.length;
return { ...prev, kindFilter: kinds[nextIdx]!, focusedEventIdx: -1 }; return { ...prev, kindFilter: kinds[nextIdx] ?? null, focusedEventIdx: -1 };
}); });
void fetchEvents();
return;
}
return; // Absorb all other input when sidebar is open
}
// ── Timeline navigation (sidebar closed) ──
// Escape reopens sidebar
if (key.escape) {
setState((prev) => ({ ...prev, showSidebar: true, focusedEventIdx: -1 }));
return; return;
} }
@@ -331,6 +368,18 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) {
return; return;
} }
// Kind filter
if (input === 'k') {
const kinds = [null, 'tool_call_trace', 'gate_decision', 'pipeline_execution', 'stage_execution', 'prompt_delivery', 'session_bind'];
setState((prev) => {
const currentIdx = kinds.indexOf(prev.kindFilter);
const nextIdx = (currentIdx + 1) % kinds.length;
return { ...prev, kindFilter: kinds[nextIdx] ?? null, focusedEventIdx: -1 };
});
void fetchEvents();
return;
}
// Enter: detail view // Enter: detail view
if (key.return) { if (key.return) {
setState((prev) => { setState((prev) => {
@@ -342,7 +391,7 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) {
return; return;
} }
// Timeline navigation // Arrow navigation
if (key.downArrow) { if (key.downArrow) {
setState((prev) => { setState((prev) => {
if (prev.focusedEventIdx === -1) return prev; if (prev.focusedEventIdx === -1) return prev;
@@ -413,6 +462,12 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) {
} }
// Main view // Main view
const sidebarHint = state.showSidebar
? '[\u2191\u2193] session [Enter] select [k] kind [Esc] close [q] quit'
: state.focusedEventIdx === -1
? '[\u2191] nav [k] kind [Enter] detail [Esc] sidebar [q] quit'
: '[\u2191\u2193] nav [PgUp/Dn] page [a] follow [k] kind [Enter] detail [Esc] sidebar [q] quit';
return ( return (
<Box flexDirection="column" height={stdout.rows}> <Box flexDirection="column" height={stdout.rows}>
{/* Header */} {/* Header */}
@@ -432,11 +487,7 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) {
{/* Footer */} {/* Footer */}
<Box borderStyle="single" borderColor="gray" paddingX={1}> <Box borderStyle="single" borderColor="gray" paddingX={1}>
<Text dimColor> <Text dimColor>{sidebarHint}</Text>
{state.focusedEventIdx === -1
? `[\u2191] nav [PgUp] page [s] session [k] kind [Enter] detail [Esc] sidebar [q] quit`
: `[\u2191\u2193] nav [PgUp/Dn] page [a] follow [s] session [k] kind [Enter] detail [Esc] sidebar [q] quit`}
</Text>
</Box> </Box>
</Box> </Box>
); );