Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 39 additions & 6 deletions backend/internal/api/handlers_events.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
}
22 changes: 22 additions & 0 deletions backend/internal/api/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}
}

// -----------------------------------------------------------------
Expand Down
16 changes: 13 additions & 3 deletions backend/internal/store/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ type Event struct {
TokensUsed int32
}

// EventListRow is the read shape (with agent_name joined).
// EventListRow is the read shape (with agent_name joined and the latest
// verdict when one exists).
type EventListRow struct {
ID string
SessionID string
Expand All @@ -35,6 +36,9 @@ type EventListRow struct {
PayloadJSON string
TokensUsed int32
CreatedAt time.Time
VerdictID string
MADCode string
Classification string
}

// TimelineRow is one entry in a session timeline: an event with its
Expand Down Expand Up @@ -103,9 +107,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...)
Expand Down Expand Up @@ -157,7 +166,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 {
Expand Down
94 changes: 63 additions & 31 deletions frontend/app/(dashboard)/events/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down Expand Up @@ -117,6 +122,7 @@ export default function EventsPage() {
<th className="px-4 py-2.5 text-[13px] font-medium">Agent</th>
<th className="px-4 py-2.5 text-[13px] font-medium">Session</th>
<th className="px-4 py-2.5 text-[13px] font-medium">Type</th>
<th className="px-4 py-2.5 text-[13px] font-medium">Severity</th>
<th className="px-4 py-2.5 text-[13px] font-medium">Run ID</th>
</tr>
</thead>
Expand Down Expand Up @@ -152,17 +158,14 @@ export default function EventsPage() {
}

function EventRow({ event, open, onToggle }: { event: EventRow; open: boolean; onToggle: () => void }) {
const [verdict, setVerdict] = useState<VerdictInfo | undefined>(undefined)
const [detail, setDetail] = useState<EventDetail | null | undefined>(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 (
<>
Expand All @@ -185,14 +188,17 @@ function EventRow({ event, open, onToggle }: { event: EventRow; open: boolean; o
<td className="px-4 py-2.5">
<Badge label={event.event_type?.replace('EVENT_TYPE_', '')} className="bg-surface-overlay text-ink-3" />
</td>
<td className="px-4 py-2.5">
<SeverityBadge madCode={event.verdict?.mad_code} />
</td>
<td className="px-4 py-2.5 font-mono text-xs text-ink-3">{event.run_id?.slice(0, 8)}</td>
</tr>

{open && (
<tr className="border-b border-surface-border/50 bg-surface-overlay/20">
<td />
<td colSpan={5} className="px-4 py-4">
<ExpandedDetail event={event} verdict={verdict} />
<td colSpan={6} className="px-4 py-4">
<ExpandedDetail event={event} detail={detail} />
</td>
</tr>
)}
Expand All @@ -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<VerdictInfo | undefined>(undefined)
const [detail, setDetail] = useState<EventDetail | null | undefined>(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 (
<div className="bg-surface-raised border border-surface-border rounded-lg overflow-hidden">
Expand All @@ -221,6 +227,7 @@ function EventCard({ event, open, onToggle }: { event: EventRow; open: boolean;
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Badge label={event.event_type?.replace('EVENT_TYPE_', '')} className="bg-surface-overlay text-ink-3" />
<SeverityBadge madCode={event.verdict?.mad_code} />
<span className="text-xs text-ink-3 font-mono ml-auto">{timeAgo(event.created_at)}</span>
</div>
<div className="text-sm text-ink/80 truncate">
Expand All @@ -234,27 +241,46 @@ function EventCard({ event, open, onToggle }: { event: EventRow; open: boolean;

{open && (
<div className="border-t border-surface-border bg-surface-overlay/20 px-4 py-3">
<ExpandedDetail event={event} verdict={verdict} />
<ExpandedDetail event={event} detail={detail} />
</div>
)}
</div>
)
}

function ExpandedDetail({ event, verdict }: { event: EventRow; verdict: VerdictInfo | undefined }) {
function SeverityBadge({ madCode }: { madCode?: string }) {
const label = severityLabel(madCode)
return (
<Badge label={label} className={`${madBadgeColor(madCode || '')}`} />
)
}

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 (
<div className="space-y-4">
<DetailBlock label="Verdict">
{verdict === undefined ? (
<p className="text-xs text-ink-3">Loading verdict...</p>
) : verdict === null ? (
{!verdict ? (
<p className="text-xs text-ink-3">No verdict recorded for this event yet.</p>
) : (
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs">
<Badge label={verdict.mad_code} className={madBadgeColor(verdict.mad_code)} />
<span className="font-mono text-ink-3">
Latency: <span className="text-ink">{verdict.latency_ms}ms</span>
</span>
{detail?.verdict && typeof detail.verdict.latency_ms === 'number' && (
<span className="font-mono text-ink-3">
Latency: <span className="text-ink">{detail.verdict.latency_ms}ms</span>
</span>
)}
<Link
href={`/sessions/${event.session_id}`}
className="text-xs text-ink-2 hover:underline font-mono ml-auto"
Expand All @@ -271,7 +297,13 @@ function ExpandedDetail({ event, verdict }: { event: EventRow; verdict: VerdictI
</DetailBlock>

<DetailBlock label="Payload">
<JsonBlock value={event.payload} />
{detail === undefined ? (
<p className="text-xs text-ink-3">Loading event details...</p>
) : detail === null ? (
<p className="text-xs text-ink-3">Unable to load event details.</p>
) : (
<JsonBlock value={detail.payload} />
)}
</DetailBlock>
</div>
)
Expand Down
1 change: 1 addition & 0 deletions frontend/tailwind.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const config: Config = {
content: [
'./app/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./lib/**/*.{ts,tsx}',
],
theme: {
extend: {
Expand Down