feat: add userName tracking to audit events

- Add userName column to AuditEvent schema with index and migration
- Add GET /api/v1/auth/me endpoint returning current user identity
- AuditCollector auto-fills userName from session→user map, resolved
  lazily via /auth/me on first session creation
- Support userName and date range (from/to) filtering on audit events
  and sessions endpoints
- Audit console sidebar groups sessions by project → user
- Add date filter presets (d key: all/today/1h/24h/7d) to console
- Add scrolling and page up/down to sidebar navigation
- Tests: auth-me (4), audit-username collector (4), route filters (2),
  smoke tests (2)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-03-07 00:18:58 +00:00
parent 75c44e4ba1
commit 86c5a61eaa
17 changed files with 689 additions and 79 deletions

View File

@@ -4,12 +4,15 @@
* 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
*
* Sidebar groups sessions by project → user.
* `d` key cycles through date filter presets.
*/
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';
import type { AuditSession, AuditEvent, AuditConsoleState, DateFilterPreset } from './audit-types.js';
import { EVENT_KIND_COLORS, EVENT_KIND_LABELS, DATE_FILTER_CYCLE, DATE_FILTER_LABELS, dateFilterToFrom } from './audit-types.js';
import http from 'node:http';
const POLL_INTERVAL_MS = 3_000;
@@ -77,22 +80,130 @@ function formatDetailPayload(payload: Record<string, unknown>): string[] {
return lines;
}
// ── Sidebar grouping ──
interface SidebarLine {
type: 'project-header' | 'user-header' | 'session';
label: string;
sessionIdx?: number; // flat index into sessions array (only for type=session)
}
function buildGroupedLines(sessions: AuditSession[]): SidebarLine[] {
// Group by project → user
const projectMap = new Map<string, Map<string, number[]>>();
const projectOrder: string[] = [];
for (let i = 0; i < sessions.length; i++) {
const s = sessions[i]!;
let userMap = projectMap.get(s.projectName);
if (!userMap) {
userMap = new Map();
projectMap.set(s.projectName, userMap);
projectOrder.push(s.projectName);
}
const userName = s.userName ?? '(unknown)';
let indices = userMap.get(userName);
if (!indices) {
indices = [];
userMap.set(userName, indices);
}
indices.push(i);
}
const lines: SidebarLine[] = [];
for (const proj of projectOrder) {
lines.push({ type: 'project-header', label: proj });
const userMap = projectMap.get(proj)!;
for (const [user, indices] of userMap) {
lines.push({ type: 'user-header', label: user });
for (const idx of indices) {
const s = sessions[idx]!;
const time = formatTime(s.lastSeen);
lines.push({
type: 'session',
label: `${s.sessionId.slice(0, 8)} \u00B7 ${s.eventCount} ev \u00B7 ${time}`,
sessionIdx: idx,
});
}
}
}
return lines;
}
/** Extract session indices in visual (grouped) order. */
function visualSessionOrder(sessions: AuditSession[]): number[] {
return buildGroupedLines(sessions)
.filter((l) => l.type === 'session')
.map((l) => l.sessionIdx!);
}
// ── Session Sidebar ──
function AuditSidebar({ sessions, selectedIdx, projectFilter }: { sessions: AuditSession[]; selectedIdx: number; projectFilter: string | null }) {
function AuditSidebar({ sessions, selectedIdx, projectFilter, dateFilter, height }: {
sessions: AuditSession[];
selectedIdx: number;
projectFilter: string | null;
dateFilter: DateFilterPreset;
height: number;
}) {
const grouped = buildGroupedLines(sessions);
const headerLines = 4; // title + filter info + blank + "All" row
const footerLines = 0;
const bodyHeight = Math.max(1, height - headerLines - footerLines);
// Find which render line corresponds to the selected session
let selectedLineIdx = -1;
if (selectedIdx >= 0) {
selectedLineIdx = grouped.findIndex((l) => l.sessionIdx === selectedIdx);
}
// Scroll to keep selected visible
let scrollStart = 0;
if (selectedLineIdx >= 0) {
if (selectedLineIdx >= scrollStart + bodyHeight) {
scrollStart = selectedLineIdx - bodyHeight + 1;
}
if (selectedLineIdx < scrollStart) {
scrollStart = selectedLineIdx;
}
}
scrollStart = Math.max(0, scrollStart);
const visibleLines = grouped.slice(scrollStart, scrollStart + bodyHeight);
return (
<Box flexDirection="column" width={30} borderStyle="single" borderColor="gray" paddingX={1}>
<Text bold>Sessions</Text>
<Text dimColor>{projectFilter ? `project: ${projectFilter}` : 'all projects'}</Text>
<Box flexDirection="column" width={34} height={height} borderStyle="single" borderColor="gray" paddingX={1}>
<Text bold>Sessions ({sessions.length})</Text>
<Text dimColor>
{projectFilter ? `project: ${projectFilter}` : 'all projects'}
{dateFilter !== 'all' ? ` \u00B7 ${DATE_FILTER_LABELS[dateFilter]}` : ''}
</Text>
<Text> </Text>
<Text color={selectedIdx === -1 ? 'cyan' : undefined} bold={selectedIdx === -1}>
{selectedIdx === -1 ? '\u25B8 ' : ' '}All ({sessions.reduce((s, x) => s + x.eventCount, 0)} events)
</Text>
{sessions.map((s, i) => {
const isSel = i === selectedIdx;
{visibleLines.map((line, vi) => {
if (line.type === 'project-header') {
return (
<Text key={`p-${line.label}-${vi}`} bold wrap="truncate">
{' '}{trunc(line.label, 28)}
</Text>
);
}
if (line.type === 'user-header') {
return (
<Text key={`u-${line.label}-${vi}`} dimColor wrap="truncate">
{' '}{trunc(line.label, 26)}
</Text>
);
}
// session
const isSel = line.sessionIdx === selectedIdx;
return (
<Text key={s.sessionId} color={isSel ? 'cyan' : undefined} bold={isSel} wrap="truncate">
{isSel ? '\u25B8 ' : ' '}{trunc(s.sessionId.slice(0, 12), 12)} <Text dimColor>{s.projectName} ({s.eventCount})</Text>
<Text key={`s-${line.sessionIdx}-${vi}`} color={isSel ? 'cyan' : undefined} bold={isSel} wrap="truncate">
{isSel ? ' \u25B8 ' : ' '}{trunc(line.label, 24)}
</Text>
);
})}
@@ -207,6 +318,7 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) {
detailScrollOffset: 0,
projectFilter: projectFilter ?? null,
kindFilter: null,
dateFilter: 'all',
});
// Use refs for polling to avoid re-creating intervals on every state change
@@ -217,8 +329,10 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) {
const fetchSessions = useCallback(async () => {
try {
const params = new URLSearchParams();
const pf = stateRef.current.projectFilter;
if (pf) params.set('projectName', pf);
const s = stateRef.current;
if (s.projectFilter) params.set('projectName', s.projectFilter);
const from = dateFilterToFrom(s.dateFilter);
if (from) params.set('from', from);
params.set('limit', '50');
const url = `${mcpdUrl}/api/v1/audit/sessions?${params.toString()}`;
const data = await fetchJson<{ sessions?: AuditSession[]; total?: number }>(url, token);
@@ -248,6 +362,8 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) {
params.set('projectName', s.projectFilter);
}
if (s.kindFilter) params.set('eventKind', s.kindFilter);
const from = dateFilterToFrom(s.dateFilter);
if (from) params.set('from', from);
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);
@@ -271,6 +387,32 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) {
return () => clearInterval(timer);
}, [fetchSessions, fetchEvents]);
// Date filter handler — shared between sidebar and timeline
const handleDateFilter = useCallback(() => {
setState((prev) => {
const currentIdx = DATE_FILTER_CYCLE.indexOf(prev.dateFilter);
const nextIdx = (currentIdx + 1) % DATE_FILTER_CYCLE.length;
const next = { ...prev, dateFilter: DATE_FILTER_CYCLE[nextIdx]!, focusedEventIdx: -1, selectedSessionIdx: -1 };
stateRef.current = next;
return next;
});
void fetchSessions();
void fetchEvents();
}, [fetchSessions, fetchEvents]);
// Kind filter handler — shared between sidebar and timeline
const handleKindFilter = useCallback(() => {
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;
const next = { ...prev, kindFilter: kinds[nextIdx] ?? null, focusedEventIdx: -1 };
stateRef.current = next;
return next;
});
void fetchEvents();
}, [fetchEvents]);
// Keyboard input
useInput((input, key) => {
const s = stateRef.current;
@@ -310,25 +452,32 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) {
// ── 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
const navigateSidebar = (direction: number, step: number = 1) => {
setState((prev) => {
const order = visualSessionOrder(prev.sessions);
if (order.length === 0) return prev;
const curPos = prev.selectedSessionIdx === -1 ? -1 : order.indexOf(prev.selectedSessionIdx);
let newPos = curPos + direction * step;
let newIdx: number;
if (newPos < 0) {
newIdx = -1; // "All" selection
} else {
newPos = Math.min(order.length - 1, Math.max(0, newPos));
newIdx = order[newPos]!;
}
if (newIdx === prev.selectedSessionIdx) return prev;
const next = { ...prev, selectedSessionIdx: newIdx, focusedEventIdx: -1 };
stateRef.current = next;
return next;
});
void fetchEvents();
return;
}
if (key.upArrow) {
setState((prev) => ({
...prev,
selectedSessionIdx: Math.max(-1, prev.selectedSessionIdx - 1),
focusedEventIdx: -1,
}));
void fetchEvents();
return;
}
};
if (key.downArrow) { navigateSidebar(1); return; }
if (key.upArrow) { navigateSidebar(-1); return; }
if (key.pageDown) { navigateSidebar(1, Math.max(1, Math.floor(stdout.rows * 0.5))); return; }
if (key.pageUp) { navigateSidebar(-1, Math.max(1, Math.floor(stdout.rows * 0.5))); return; }
if (key.return) {
// Enter closes sidebar, keeping the selected session
setState((prev) => ({ ...prev, showSidebar: false, focusedEventIdx: -1 }));
@@ -339,17 +488,8 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) {
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;
}
if (input === 'k') { handleKindFilter(); return; }
if (input === 'd') { handleDateFilter(); return; }
return; // Absorb all other input when sidebar is open
}
@@ -368,17 +508,8 @@ 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;
}
if (input === 'k') { handleKindFilter(); return; }
if (input === 'd') { handleDateFilter(); return; }
// Enter: detail view
if (key.return) {
@@ -463,10 +594,10 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) {
// Main view
const sidebarHint = state.showSidebar
? '[\u2191\u2193] session [Enter] select [k] kind [Esc] close [q] quit'
? '[\u2191\u2193] session [Enter] select [k] kind [d] date [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';
? '[\u2191] nav [k] kind [d] date [Enter] detail [Esc] sidebar [q] quit'
: '[\u2191\u2193] nav [PgUp/Dn] page [a] follow [k] kind [d] date [Enter] detail [Esc] sidebar [q] quit';
return (
<Box flexDirection="column" height={stdout.rows}>
@@ -474,13 +605,20 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) {
<Box paddingX={1}>
<Text bold color="cyan">Audit Console</Text>
<Text dimColor> {state.totalEvents} total events</Text>
{state.kindFilter && <Text color="yellow"> filter: {EVENT_KIND_LABELS[state.kindFilter] ?? state.kindFilter}</Text>}
{state.kindFilter && <Text color="yellow"> kind: {EVENT_KIND_LABELS[state.kindFilter] ?? state.kindFilter}</Text>}
{state.dateFilter !== 'all' && <Text color="magenta"> date: {DATE_FILTER_LABELS[state.dateFilter]}</Text>}
</Box>
{/* Body */}
<Box flexGrow={1}>
{state.showSidebar && (
<AuditSidebar sessions={state.sessions} selectedIdx={state.selectedSessionIdx} projectFilter={state.projectFilter} />
<AuditSidebar
sessions={state.sessions}
selectedIdx={state.selectedSessionIdx}
projectFilter={state.projectFilter}
dateFilter={state.dateFilter}
height={height}
/>
)}
<AuditTimeline events={state.events} height={height} focusedIdx={state.focusedEventIdx} />
</Box>

View File

@@ -5,6 +5,7 @@
export interface AuditSession {
sessionId: string;
projectName: string;
userName: string | null;
firstSeen: string;
lastSeen: string;
eventCount: number;
@@ -46,6 +47,34 @@ export interface AuditConsoleState {
// Filters
projectFilter: string | null;
kindFilter: string | null;
dateFilter: 'all' | '1h' | '24h' | '7d' | 'today';
}
export type DateFilterPreset = 'all' | '1h' | '24h' | '7d' | 'today';
export const DATE_FILTER_CYCLE: DateFilterPreset[] = ['all', 'today', '1h', '24h', '7d'];
export const DATE_FILTER_LABELS: Record<DateFilterPreset, string> = {
'all': 'all time',
'today': 'today',
'1h': 'last hour',
'24h': 'last 24h',
'7d': 'last 7 days',
};
export function dateFilterToFrom(preset: DateFilterPreset): string | undefined {
if (preset === 'all') return undefined;
const now = new Date();
switch (preset) {
case '1h': return new Date(now.getTime() - 60 * 60 * 1000).toISOString();
case '24h': return new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString();
case '7d': return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
case 'today': {
const start = new Date(now);
start.setHours(0, 0, 0, 0);
return start.toISOString();
}
}
}
export const EVENT_KIND_COLORS: Record<string, string> = {