diff --git a/backend/internal/api/handlers_events.go b/backend/internal/api/handlers_events.go index 2c2a522..c894e56 100644 --- a/backend/internal/api/handlers_events.go +++ b/backend/internal/api/handlers_events.go @@ -27,11 +27,23 @@ type eventResponse struct { CreatedAt string `json:"created_at"` } +type eventListItemResponse struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + AgentID string `json:"agent_id"` + AgentName string `json:"agent_name"` + AgentProfileID *string `json:"agent_profile_id"` + EventType string `json:"event_type"` + RunID string `json:"run_id"` + CreatedAt string `json:"created_at"` + Verdict *timelineVerdict `json:"verdict,omitempty"` +} + type eventListResponse struct { - Events []eventResponse `json:"events"` - Total int `json:"total"` - Page int `json:"page"` - PerPage int `json:"per_page"` + Events []eventListItemResponse `json:"events"` + Total int `json:"total"` + Page int `json:"page"` + PerPage int `json:"per_page"` } type eventDetailResponse struct { @@ -87,13 +99,13 @@ func (s *Server) handleListEvents(w http.ResponseWriter, r *http.Request) { } resp := eventListResponse{ - Events: make([]eventResponse, 0, len(rows)), + Events: make([]eventListItemResponse, 0, len(rows)), Total: total, Page: pg.Page, PerPage: pg.PerPage, } for _, row := range rows { - resp.Events = append(resp.Events, eventToResponse(row)) + resp.Events = append(resp.Events, eventToListItemResponse(row)) } writeJSON(w, http.StatusOK, resp) } @@ -154,7 +166,7 @@ func (s *Server) handleSessionTimeline(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, resp) } -func eventToResponse(r *store.EventListRow) eventResponse { +func eventToResponse(r *store.EventDetailRow) eventResponse { resp := eventResponse{ ID: r.ID, SessionID: r.SessionID, @@ -172,3 +184,24 @@ func eventToResponse(r *store.EventListRow) eventResponse { } return resp } + +func eventToListItemResponse(r *store.EventListRow) eventListItemResponse { + resp := eventListItemResponse{ + ID: r.ID, + SessionID: r.SessionID, + AgentID: r.AgentID, + AgentName: r.AgentName, + AgentProfileID: r.AgentProfileID, + EventType: r.EventType, + RunID: r.RunID, + CreatedAt: r.CreatedAt.UTC().Format("2006-01-02T15:04:05.000Z"), + } + if r.VerdictID != "" { + resp.Verdict = &timelineVerdict{ + ID: r.VerdictID, + MADCode: r.MADCode, + Classification: r.Classification, + } + } + return resp +} diff --git a/backend/internal/api/handlers_test.go b/backend/internal/api/handlers_test.go index a37a9f0..a9183e0 100644 --- a/backend/internal/api/handlers_test.go +++ b/backend/internal/api/handlers_test.go @@ -998,6 +998,28 @@ func TestEventsMinMADFilterUsesLatestVerdict(t *testing.T) { if int(data["total"].(float64)) != 2 { t.Errorf("no-filter total = %v, want 2", data["total"]) } + events = data["events"].([]any) + verdictsByEvent := map[string]string{} + for _, item := range events { + event := item.(map[string]any) + if _, ok := event["payload"]; ok { + t.Fatalf("list event %v should not include payload", event["id"]) + } + if _, ok := event["tokens_used"]; ok { + t.Fatalf("list event %v should not include tokens_used", event["id"]) + } + verdict, ok := event["verdict"].(map[string]any) + if !ok { + t.Fatalf("event %v should include latest verdict", event["id"]) + } + verdictsByEvent[event["id"].(string)] = verdict["mad_code"].(string) + } + if verdictsByEvent[eA] != "M0" { + t.Errorf("event A latest verdict = %v, want M0", verdictsByEvent[eA]) + } + if verdictsByEvent[eB] != "M3.a" { + t.Errorf("event B latest verdict = %v, want M3.a", verdictsByEvent[eB]) + } } // ----------------------------------------------------------------- diff --git a/backend/internal/store/events.go b/backend/internal/store/events.go index a388a01..279826b 100644 --- a/backend/internal/store/events.go +++ b/backend/internal/store/events.go @@ -23,7 +23,10 @@ type Event struct { TokensUsed int32 } -// EventListRow is the read shape (with agent_name joined). +// EventListRow is the read shape returned by ListEvents (with +// agent_name joined and the latest verdict when one exists). It omits +// the full payload and token count because the list query does not +// select them. type EventListRow struct { ID string SessionID string @@ -32,9 +35,18 @@ type EventListRow struct { AgentName string EventType string RunID string - PayloadJSON string - TokensUsed int32 CreatedAt time.Time + VerdictID string + MADCode string + Classification string +} + +// EventDetailRow is the read shape returned by GetEvent. It extends +// the list row with fields only selected by the detail query. +type EventDetailRow struct { + EventListRow + PayloadJSON string + TokensUsed int32 } // TimelineRow is one entry in a session timeline: an event with its @@ -103,9 +115,14 @@ func (s *Store) ListEvents(ctx context.Context, f EventFilters, perPage, offset rows, err := s.db.QueryContext(ctx, `SELECT e.id, e.session_id, COALESCE(e.agent_id, ''), e.agent_profile_id, COALESCE(ap.name, ''), e.event_type, COALESCE(e.run_id, ''), - e.payload, e.tokens_used, e.created_at + e.created_at, + COALESCE(v.id, ''), COALESCE(v.mad_code, ''), COALESCE(v.classification, '') FROM events e LEFT JOIN agent_profiles ap ON ap.id = e.agent_profile_id + LEFT JOIN verdicts v ON v.event_id = e.id + AND v.created_at = ( + SELECT max(v2.created_at) FROM verdicts v2 WHERE v2.event_id = e.id + ) WHERE `+where+` ORDER BY e.created_at DESC LIMIT ? OFFSET ?`, args...) @@ -127,7 +144,7 @@ func (s *Store) ListEvents(ctx context.Context, f EventFilters, perPage, offset // GetEvent returns one event by id (with agent_name joined), or // ErrNotFound. -func (s *Store) GetEvent(ctx context.Context, id string) (*EventListRow, error) { +func (s *Store) GetEvent(ctx context.Context, id string) (*EventDetailRow, error) { row := s.db.QueryRowContext(ctx, `SELECT e.id, e.session_id, COALESCE(e.agent_id, ''), e.agent_profile_id, COALESCE(ap.name, ''), e.event_type, COALESCE(e.run_id, ''), @@ -135,7 +152,7 @@ func (s *Store) GetEvent(ctx context.Context, id string) (*EventListRow, error) FROM events e LEFT JOIN agent_profiles ap ON ap.id = e.agent_profile_id WHERE e.id = ?`, id) - r := &EventListRow{} + r := &EventDetailRow{} var agentProfileID sql.NullString var createdAt string if err := row.Scan(&r.ID, &r.SessionID, &r.AgentID, &agentProfileID, &r.AgentName, @@ -157,7 +174,8 @@ func scanEventListRow(rows *sql.Rows) (*EventListRow, error) { var agentProfileID sql.NullString var createdAt string if err := rows.Scan(&r.ID, &r.SessionID, &r.AgentID, &agentProfileID, &r.AgentName, - &r.EventType, &r.RunID, &r.PayloadJSON, &r.TokensUsed, &createdAt); err != nil { + &r.EventType, &r.RunID, &createdAt, + &r.VerdictID, &r.MADCode, &r.Classification); err != nil { return nil, err } if agentProfileID.Valid { diff --git a/frontend/app/(dashboard)/events/page.tsx b/frontend/app/(dashboard)/events/page.tsx index 5ef9376..236201c 100644 --- a/frontend/app/(dashboard)/events/page.tsx +++ b/frontend/app/(dashboard)/events/page.tsx @@ -20,17 +20,22 @@ type EventRow = { agent_name: string event_type: string run_id: string - payload: any created_at: string + verdict?: EventVerdict } -type VerdictInfo = { +type EventVerdict = { id: string mad_code: string classification: string - latency_ms: number - created_at: string -} | null + latency_ms?: number | null +} + +type EventDetail = EventRow & { + payload: any + tokens_used: number + verdict?: EventVerdict | null +} export default function EventsPage() { const [data, setData] = useState<{ events: EventRow[]; total: number }>({ events: [], total: 0 }) @@ -117,6 +122,7 @@ export default function EventsPage() { Agent Session Type + Severity Run ID @@ -152,17 +158,14 @@ export default function EventsPage() { } function EventRow({ event, open, onToggle }: { event: EventRow; open: boolean; onToggle: () => void }) { - const [verdict, setVerdict] = useState(undefined) + const [detail, setDetail] = useState(undefined) - // Lazy-load the verdict for this event the first time the row is opened. - // The list endpoint doesn't return verdict fields (only the per-event GET - // does), so we fetch on demand to keep the list response small. useEffect(() => { - if (!open || verdict !== undefined) return - api<{ data: { verdict: VerdictInfo } }>(`/api/events/${event.id}`) - .then(r => setVerdict(r.data?.verdict ?? null)) - .catch(() => setVerdict(null)) - }, [open, event.id, verdict]) + if (!open || detail !== undefined) return + api<{ data: EventDetail }>(`/api/events/${event.id}`) + .then(r => setDetail(r.data ?? null)) + .catch(() => setDetail(null)) + }, [open, event.id, detail]) return ( <> @@ -185,14 +188,17 @@ function EventRow({ event, open, onToggle }: { event: EventRow; open: boolean; o + + + {event.run_id?.slice(0, 8)} {open && ( - - + + )} @@ -201,14 +207,14 @@ function EventRow({ event, open, onToggle }: { event: EventRow; open: boolean; o } function EventCard({ event, open, onToggle }: { event: EventRow; open: boolean; onToggle: () => void }) { - const [verdict, setVerdict] = useState(undefined) + const [detail, setDetail] = useState(undefined) useEffect(() => { - if (!open || verdict !== undefined) return - api<{ data: { verdict: VerdictInfo } }>(`/api/events/${event.id}`) - .then(r => setVerdict(r.data?.verdict ?? null)) - .catch(() => setVerdict(null)) - }, [open, event.id, verdict]) + if (!open || detail !== undefined) return + api<{ data: EventDetail }>(`/api/events/${event.id}`) + .then(r => setDetail(r.data ?? null)) + .catch(() => setDetail(null)) + }, [open, event.id, detail]) return (
@@ -221,6 +227,7 @@ function EventCard({ event, open, onToggle }: { event: EventRow; open: boolean;
+ {timeAgo(event.created_at)}
@@ -234,27 +241,46 @@ function EventCard({ event, open, onToggle }: { event: EventRow; open: boolean; {open && (
- +
)}
) } -function ExpandedDetail({ event, verdict }: { event: EventRow; verdict: VerdictInfo | undefined }) { +function SeverityBadge({ madCode }: { madCode?: string }) { + const label = severityLabel(madCode) + return ( + + ) +} + +function severityLabel(madCode?: string): string { + if (!madCode) return 'Unknown' + if (madCode.startsWith('M4')) return 'Critical' + if (madCode.startsWith('M3')) return 'High' + if (madCode.startsWith('M2')) return 'Medium' + if (madCode.startsWith('M1')) return 'Low' + if (madCode.startsWith('M0')) return 'Safe' + return 'Unknown' +} + +function ExpandedDetail({ event, detail }: { event: EventRow; detail: EventDetail | null | undefined }) { + const verdict = detail?.verdict ?? event.verdict + return (
- {verdict === undefined ? ( -

Loading verdict...

- ) : verdict === null ? ( + {!verdict ? (

No verdict recorded for this event yet.

) : (
- - Latency: {verdict.latency_ms}ms - + {detail?.verdict && typeof detail.verdict.latency_ms === 'number' && ( + + Latency: {detail.verdict.latency_ms}ms + + )} - + {detail === undefined ? ( +

Loading event details...

+ ) : detail === null ? ( +

Unable to load event details.

+ ) : ( + + )}
) diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts index 8b1065d..1a68a91 100644 --- a/frontend/tailwind.config.ts +++ b/frontend/tailwind.config.ts @@ -5,6 +5,7 @@ const config: Config = { content: [ './app/**/*.{ts,tsx}', './components/**/*.{ts,tsx}', + './lib/**/*.{ts,tsx}', ], theme: { extend: {