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.
*
* Shows sessions in a sidebar and events in a timeline.
* Polls mcpd periodically for new data.
* Navigation follows the same patterns as the main unified console:
* - 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 type { AuditSession, AuditEvent, AuditConsoleState } 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,
});
// 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 () => {
try {
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');
const url = `${mcpdUrl}/api/v1/audit/sessions?${params.toString()}`;
const data = await fetchJson<{ sessions: AuditSession[]; total: number }>(url, token);
setState((prev) => ({ ...prev, sessions: data.sessions, phase: 'ready' }));
} catch (err) {
setState((prev) => ({ ...prev, phase: 'error', error: err instanceof Error ? err.message : String(err) }));
const data = await fetchJson<{ sessions?: AuditSession[]; total?: number }>(url, token);
if (data.sessions && Array.isArray(data.sessions)) {
setState((prev) => ({ ...prev, sessions: data.sessions!, phase: 'ready' }));
}
}, [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 () => {
try {
const s = stateRef.current;
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) {
params.set('sessionId', selectedSession.sessionId);
} else if (state.projectFilter) {
params.set('projectName', state.projectFilter);
} else if (s.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));
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
setState((prev) => ({ ...prev, events: data.events.reverse(), totalEvents: data.total }));
setState((prev) => ({ ...prev, events: data.events!.reverse(), totalEvents: data.total ?? data.events!.length }));
}
} catch {
// 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(() => {
void fetchSessions();
void fetchEvents();
@@ -256,21 +273,17 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) {
// Keyboard input
useInput((input, key) => {
const s = stateRef.current;
// Quit
if (input === 'q') {
exit();
return;
}
// Toggle sidebar
// ── Detail view navigation ──
if (s.detailEvent) {
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 }));
return;
}
@@ -295,22 +308,34 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) {
return;
}
// Sidebar mode: Tab toggles focus between sidebar and timeline
if (key.tab && state.showSidebar) {
// Tab cycles: session list focus ↔ timeline focus
// Use selectedSessionIdx >= -2 as "sidebar focused" indicator
// Actually, let's use a simpler approach: Shift+Tab or 's' focuses sidebar
// ── Sidebar navigation (arrows = sessions, Enter = select, Escape = close) ──
if (s.showSidebar) {
if (key.downArrow) {
setState((prev) => ({
...prev,
selectedSessionIdx: Math.min(prev.sessions.length - 1, prev.selectedSessionIdx + 1),
focusedEventIdx: -1,
}));
// Re-fetch events for the newly selected session
void fetchEvents();
return;
}
// Session navigation (when sidebar visible)
if (state.showSidebar && (input === 'S' || input === 's')) {
// s/S cycles through sessions
setState((prev) => {
const max = prev.sessions.length - 1;
const next = prev.selectedSessionIdx >= max ? -1 : prev.selectedSessionIdx + 1;
return { ...prev, selectedSessionIdx: next, focusedEventIdx: -1 };
});
if (key.upArrow) {
setState((prev) => ({
...prev,
selectedSessionIdx: Math.max(-1, prev.selectedSessionIdx - 1),
focusedEventIdx: -1,
}));
void fetchEvents();
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;
}
@@ -320,8 +345,20 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) {
setState((prev) => {
const currentIdx = kinds.indexOf(prev.kindFilter);
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;
}
@@ -331,6 +368,18 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) {
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
if (key.return) {
setState((prev) => {
@@ -342,7 +391,7 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) {
return;
}
// Timeline navigation
// Arrow navigation
if (key.downArrow) {
setState((prev) => {
if (prev.focusedEventIdx === -1) return prev;
@@ -413,6 +462,12 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) {
}
// 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 (
<Box flexDirection="column" height={stdout.rows}>
{/* Header */}
@@ -432,11 +487,7 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) {
{/* Footer */}
<Box borderStyle="single" borderColor="gray" paddingX={1}>
<Text dimColor>
{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>
<Text dimColor>{sidebarHint}</Text>
</Box>
</Box>
);