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:
@@ -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>
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
Reference in New Issue
Block a user