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:
@@ -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' }));
|
||||
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' }));
|
||||
}
|
||||
} catch (err) {
|
||||
setState((prev) => ({ ...prev, phase: 'error', error: err instanceof Error ? err.message : String(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, state.projectFilter]);
|
||||
}, [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);
|
||||
// API returns newest first — reverse for timeline display
|
||||
setState((prev) => ({ ...prev, events: data.events.reverse(), totalEvents: data.total }));
|
||||
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 ?? 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
|
||||
if (key.escape) {
|
||||
setState((prev) => ({ ...prev, showSidebar: !prev.showSidebar }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Detail view navigation
|
||||
if (state.detailEvent) {
|
||||
if (input === 'q' || key.escape) {
|
||||
// ── Detail view navigation ──
|
||||
if (s.detailEvent) {
|
||||
if (key.escape) {
|
||||
setState((prev) => ({ ...prev, detailEvent: null, detailScrollOffset: 0 }));
|
||||
return;
|
||||
}
|
||||
@@ -295,33 +308,57 @@ 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
|
||||
return;
|
||||
// ── 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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
// Kind filter: k cycles through event kinds
|
||||
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;
|
||||
}
|
||||
|
||||
return; // Absorb all other input when sidebar is open
|
||||
}
|
||||
|
||||
// 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 };
|
||||
});
|
||||
return;
|
||||
}
|
||||
// ── Timeline navigation (sidebar closed) ──
|
||||
|
||||
// Kind filter: k cycles through event kinds
|
||||
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]!, focusedEventIdx: -1 };
|
||||
});
|
||||
// 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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user