@tonyclaw/agent-inspector 2.1.3 → 2.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/{CompareDrawer-BLYcm6Dg.js → CompareDrawer-SAn2KFl2.js} +1 -1
  3. package/.output/public/assets/ProxyViewerContainer-DaiTFf7n.js +117 -0
  4. package/.output/public/assets/{ReplayDialog-_kCy9L9E.js → ReplayDialog-CCwz0YUV.js} +1 -1
  5. package/.output/public/assets/{RequestAnatomy-DWp5pWsv.js → RequestAnatomy-DXcC4ydl.js} +1 -1
  6. package/.output/public/assets/{ResponseView-Ci92o6mt.js → ResponseView-Bh9t9aZF.js} +1 -1
  7. package/.output/public/assets/{StreamingChunkSequence-C-1ZQl_b.js → StreamingChunkSequence-B69M2rr-.js} +1 -1
  8. package/.output/public/assets/_sessionId-6fF5JpyQ.js +1 -0
  9. package/.output/public/assets/index-Bcq8bZoK.css +1 -0
  10. package/.output/public/assets/index-DBSbw8Gd.js +1 -0
  11. package/.output/public/assets/{main-RgLvmIQk.js → main-DZVKUeg6.js} +2 -2
  12. package/.output/server/_libs/lucide-react.mjs +204 -173
  13. package/.output/server/_libs/radix-ui__react-dialog.mjs +2 -0
  14. package/.output/server/{_sessionId-DnOYbKO7.mjs → _sessionId-COVUf_EG.mjs} +3 -3
  15. package/.output/server/_ssr/{CompareDrawer-D44_F13_.mjs → CompareDrawer-DqQHO4tk.mjs} +3 -3
  16. package/.output/server/_ssr/{ProxyViewerContainer-Cx4p_awW.mjs → ProxyViewerContainer-BEeF4V-C.mjs} +1006 -75
  17. package/.output/server/_ssr/{ReplayDialog-wScCF3Oc.mjs → ReplayDialog-DUZbsd5x.mjs} +4 -4
  18. package/.output/server/_ssr/{RequestAnatomy-Cf7R7PXS.mjs → RequestAnatomy-DKuIxoBj.mjs} +2 -2
  19. package/.output/server/_ssr/{ResponseView-7pVIZLL-.mjs → ResponseView-BB9CYmTJ.mjs} +3 -3
  20. package/.output/server/_ssr/{StreamingChunkSequence-Bs57_GbC.mjs → StreamingChunkSequence-CkY3uTjF.mjs} +3 -3
  21. package/.output/server/_ssr/{index-6eY__la9.mjs → index-CHTchkrO.mjs} +2 -2
  22. package/.output/server/_ssr/index.mjs +2 -2
  23. package/.output/server/_ssr/{router-DRZPhZB6.mjs → router-C8DJ5pQZ.mjs} +1972 -221
  24. package/.output/server/_tanstack-start-manifest_v-Z1ffcxDr.mjs +4 -0
  25. package/.output/server/index.mjs +68 -68
  26. package/package.json +2 -1
  27. package/src/components/ProxyViewer.tsx +87 -1
  28. package/src/components/ProxyViewerContainer.tsx +26 -0
  29. package/src/components/alerts/AlertsDialog.tsx +610 -0
  30. package/src/components/groups/GroupsDialog.tsx +41 -13
  31. package/src/components/proxy-viewer/LogEntry.tsx +324 -51
  32. package/src/components/ui/dialog.tsx +1 -0
  33. package/src/contracts/index.ts +15 -2
  34. package/src/contracts/log.ts +18 -0
  35. package/src/lib/alertContract.ts +79 -0
  36. package/src/lib/apiClient.ts +39 -0
  37. package/src/lib/export-logs.ts +47 -9
  38. package/src/lib/groupContract.ts +1 -0
  39. package/src/lib/logImportContract.ts +12 -0
  40. package/src/lib/useAlerts.ts +82 -0
  41. package/src/lib/useGroupEvidence.ts +6 -2
  42. package/src/lib/useGroups.ts +6 -2
  43. package/src/proxy/alerts.ts +664 -0
  44. package/src/proxy/logBodyChunks.ts +91 -0
  45. package/src/proxy/logImporter.ts +491 -0
  46. package/src/proxy/logIndex.ts +63 -2
  47. package/src/proxy/sqliteLogIndex.ts +612 -0
  48. package/src/proxy/store.ts +32 -7
  49. package/src/routes/api/alerts.summary.ts +24 -0
  50. package/src/routes/api/alerts.ts +52 -0
  51. package/src/routes/api/logs.$id.body.ts +44 -0
  52. package/src/routes/api/logs.import.ts +50 -0
  53. package/src/services/alerts.ts +12 -0
  54. package/src/services/groups.ts +1 -0
  55. package/.output/public/assets/ProxyViewerContainer-Ba5wFn00.js +0 -117
  56. package/.output/public/assets/_sessionId-DKzAmSNP.js +0 -1
  57. package/.output/public/assets/index-B-MZ9lQM.css +0 -1
  58. package/.output/public/assets/index-ZZHwgkWJ.js +0 -1
  59. package/.output/server/_tanstack-start-manifest_v-DfAVFkZ5.mjs +0 -4
@@ -0,0 +1,610 @@
1
+ import { type JSX, useEffect, useMemo, useState } from "react";
2
+ import { AlertTriangle, Bell, Clock3, Loader2, RefreshCw, ShieldAlert, Siren } from "lucide-react";
3
+ import type { Alert, AlertCategory, AlertSeverity, AlertSource } from "../../lib/alertContract";
4
+ import { type AlertListFilters, useAlerts, useAlertSummary } from "../../lib/useAlerts";
5
+ import { cn } from "../../lib/utils";
6
+ import { Badge } from "../ui/badge";
7
+ import { Button } from "../ui/button";
8
+ import {
9
+ Dialog,
10
+ DialogContent,
11
+ DialogDescription,
12
+ DialogHeader,
13
+ DialogTitle,
14
+ DialogTrigger,
15
+ } from "../ui/dialog";
16
+
17
+ type SeverityFilter = AlertSeverity | "all";
18
+ type CategoryFilter = AlertCategory | "all";
19
+
20
+ const ALERT_CATEGORIES: AlertCategory[] = [
21
+ "request-failure",
22
+ "request-timeout",
23
+ "slow-response",
24
+ "tool-schema",
25
+ "run-failure",
26
+ "group-failure",
27
+ "group-incomplete",
28
+ ];
29
+
30
+ function severityLabel(severity: AlertSeverity): string {
31
+ switch (severity) {
32
+ case "critical":
33
+ return "Critical";
34
+ case "warning":
35
+ return "Warning";
36
+ case "notice":
37
+ return "Notice";
38
+ }
39
+ }
40
+
41
+ function sourceLabel(source: AlertSource): string {
42
+ switch (source) {
43
+ case "proxy":
44
+ return "Proxy";
45
+ case "tool-schema":
46
+ return "Tool schema";
47
+ case "run":
48
+ return "Run";
49
+ case "group":
50
+ return "Group";
51
+ }
52
+ }
53
+
54
+ function categoryLabel(category: AlertCategory): string {
55
+ switch (category) {
56
+ case "request-failure":
57
+ return "Request failure";
58
+ case "request-timeout":
59
+ return "Timeout";
60
+ case "slow-response":
61
+ return "Slow response";
62
+ case "tool-schema":
63
+ return "Tool calling";
64
+ case "run-failure":
65
+ return "Run failure";
66
+ case "group-failure":
67
+ return "Group failure";
68
+ case "group-incomplete":
69
+ return "Group incomplete";
70
+ }
71
+ }
72
+
73
+ function severityAccent(severity: AlertSeverity): string {
74
+ switch (severity) {
75
+ case "critical":
76
+ return "border-red-400/40 bg-red-500/12 text-red-100";
77
+ case "warning":
78
+ return "border-amber-300/35 bg-amber-400/12 text-amber-100";
79
+ case "notice":
80
+ return "border-sky-300/35 bg-sky-400/12 text-sky-100";
81
+ }
82
+ }
83
+
84
+ function severityDot(severity: AlertSeverity): string {
85
+ switch (severity) {
86
+ case "critical":
87
+ return "bg-red-400";
88
+ case "warning":
89
+ return "bg-amber-300";
90
+ case "notice":
91
+ return "bg-sky-300";
92
+ }
93
+ }
94
+
95
+ function severityText(severity: AlertSeverity): string {
96
+ switch (severity) {
97
+ case "critical":
98
+ return "text-red-200";
99
+ case "warning":
100
+ return "text-amber-100";
101
+ case "notice":
102
+ return "text-sky-100";
103
+ }
104
+ }
105
+
106
+ function categoryIcon(category: AlertCategory): JSX.Element {
107
+ switch (category) {
108
+ case "request-failure":
109
+ case "run-failure":
110
+ case "group-failure":
111
+ return <ShieldAlert className="size-3.5" />;
112
+ case "request-timeout":
113
+ case "slow-response":
114
+ return <Clock3 className="size-3.5" />;
115
+ case "tool-schema":
116
+ return <AlertTriangle className="size-3.5" />;
117
+ case "group-incomplete":
118
+ return <Bell className="size-3.5" />;
119
+ }
120
+ }
121
+
122
+ function formatTimestamp(value: string): string {
123
+ const millis = Date.parse(value);
124
+ if (!Number.isFinite(millis)) return value;
125
+ return new Intl.DateTimeFormat(undefined, {
126
+ month: "short",
127
+ day: "2-digit",
128
+ hour: "2-digit",
129
+ minute: "2-digit",
130
+ hour12: false,
131
+ }).format(new Date(millis));
132
+ }
133
+
134
+ function formatCount(value: number): string {
135
+ return value.toLocaleString();
136
+ }
137
+
138
+ function providerModel(alert: Alert): string {
139
+ if (alert.provider !== null && alert.model !== null) return `${alert.provider} / ${alert.model}`;
140
+ if (alert.provider !== null) return alert.provider;
141
+ if (alert.model !== null) return alert.model;
142
+ return "Unknown target";
143
+ }
144
+
145
+ function filtersFrom(severity: SeverityFilter, category: CategoryFilter): AlertListFilters {
146
+ const filters: AlertListFilters = { limit: 100 };
147
+ if (severity !== "all") filters.severity = severity;
148
+ if (category !== "all") filters.category = category;
149
+ return filters;
150
+ }
151
+
152
+ function selectedAlertFrom(alerts: readonly Alert[], selectedAlertId: string | null): Alert | null {
153
+ if (selectedAlertId !== null) {
154
+ const selected = alerts.find((alert) => alert.id === selectedAlertId);
155
+ if (selected !== undefined) return selected;
156
+ }
157
+ return alerts[0] ?? null;
158
+ }
159
+
160
+ function AlertSummaryPill({
161
+ label,
162
+ count,
163
+ tone,
164
+ }: {
165
+ label: string;
166
+ count: number;
167
+ tone: "critical" | "warning" | "notice" | "neutral";
168
+ }): JSX.Element {
169
+ const toneClass =
170
+ tone === "critical"
171
+ ? "border-red-400/30 bg-red-500/10 text-red-100"
172
+ : tone === "warning"
173
+ ? "border-amber-300/30 bg-amber-400/10 text-amber-100"
174
+ : tone === "notice"
175
+ ? "border-sky-300/30 bg-sky-400/10 text-sky-100"
176
+ : "border-border bg-muted/40 text-foreground";
177
+
178
+ return (
179
+ <div className={cn("rounded-md border px-3 py-2", toneClass)}>
180
+ <div className="text-[10px] font-semibold uppercase opacity-75">{label}</div>
181
+ <div className="mt-1 text-lg font-semibold leading-none">{formatCount(count)}</div>
182
+ </div>
183
+ );
184
+ }
185
+
186
+ function SeverityFilterButton({
187
+ value,
188
+ active,
189
+ label,
190
+ count,
191
+ onClick,
192
+ }: {
193
+ value: SeverityFilter;
194
+ active: boolean;
195
+ label: string;
196
+ count: number | null;
197
+ onClick: (value: SeverityFilter) => void;
198
+ }): JSX.Element {
199
+ return (
200
+ <Button
201
+ type="button"
202
+ variant={active ? "secondary" : "ghost"}
203
+ size="sm"
204
+ aria-pressed={active}
205
+ onClick={() => onClick(value)}
206
+ className="h-7 px-2"
207
+ >
208
+ {label}
209
+ {count !== null ? <Badge variant="outline">{formatCount(count)}</Badge> : null}
210
+ </Button>
211
+ );
212
+ }
213
+
214
+ function CategoryFilterButton({
215
+ category,
216
+ active,
217
+ count,
218
+ onClick,
219
+ }: {
220
+ category: AlertCategory;
221
+ active: boolean;
222
+ count: number;
223
+ onClick: (category: CategoryFilter) => void;
224
+ }): JSX.Element {
225
+ return (
226
+ <Button
227
+ type="button"
228
+ variant={active ? "secondary" : "ghost"}
229
+ size="sm"
230
+ aria-pressed={active}
231
+ onClick={() => onClick(category)}
232
+ className="h-7 px-2"
233
+ >
234
+ {categoryIcon(category)}
235
+ {categoryLabel(category)}
236
+ <Badge variant="outline">{formatCount(count)}</Badge>
237
+ </Button>
238
+ );
239
+ }
240
+
241
+ function AlertList({
242
+ alerts,
243
+ selectedAlertId,
244
+ isLoading,
245
+ error,
246
+ onSelect,
247
+ }: {
248
+ alerts: Alert[];
249
+ selectedAlertId: string | null;
250
+ isLoading: boolean;
251
+ error: Error | undefined;
252
+ onSelect: (alertId: string) => void;
253
+ }): JSX.Element {
254
+ if (isLoading) {
255
+ return (
256
+ <div className="flex min-h-56 items-center justify-center rounded-md border border-dashed border-border text-xs text-muted-foreground">
257
+ <Loader2 className="mr-2 size-3.5 animate-spin" />
258
+ Loading alerts...
259
+ </div>
260
+ );
261
+ }
262
+
263
+ if (error !== undefined) {
264
+ return (
265
+ <div className="rounded-md border border-red-300/30 bg-red-500/10 px-3 py-2 text-xs text-red-100">
266
+ {error.message}
267
+ </div>
268
+ );
269
+ }
270
+
271
+ if (alerts.length === 0) {
272
+ return (
273
+ <div className="flex min-h-56 items-center justify-center rounded-md border border-dashed border-border px-4 text-center text-xs text-muted-foreground">
274
+ No alerts match the current filters.
275
+ </div>
276
+ );
277
+ }
278
+
279
+ return (
280
+ <div className="min-h-0 space-y-2 overflow-y-auto pr-1">
281
+ {alerts.map((alert) => {
282
+ const selected = alert.id === selectedAlertId;
283
+ return (
284
+ <button
285
+ type="button"
286
+ key={alert.id}
287
+ onClick={() => onSelect(alert.id)}
288
+ className={cn(
289
+ "w-full rounded-md border px-3 py-3 text-left transition-colors",
290
+ selected
291
+ ? "border-primary/60 bg-primary/12"
292
+ : "border-border bg-background/70 hover:border-primary/30 hover:bg-muted/60",
293
+ )}
294
+ >
295
+ <div className="flex items-start justify-between gap-3">
296
+ <div className="min-w-0">
297
+ <div className="flex min-w-0 items-center gap-2">
298
+ <span className={cn("size-2 rounded-full", severityDot(alert.severity))} />
299
+ <span className={cn("text-xs font-semibold", severityText(alert.severity))}>
300
+ {severityLabel(alert.severity)}
301
+ </span>
302
+ <span className="truncate text-xs text-muted-foreground">
303
+ {categoryLabel(alert.category)}
304
+ </span>
305
+ </div>
306
+ <div className="mt-1 truncate text-sm font-semibold">{alert.title}</div>
307
+ <div className="mt-1 line-clamp-2 text-xs text-muted-foreground">
308
+ {alert.message}
309
+ </div>
310
+ </div>
311
+ {alert.count > 1 ? (
312
+ <Badge variant="outline" className="shrink-0">
313
+ x{formatCount(alert.count)}
314
+ </Badge>
315
+ ) : null}
316
+ </div>
317
+ <div className="mt-2 flex min-w-0 items-center justify-between gap-2 text-[11px] text-muted-foreground">
318
+ <span className="truncate">{providerModel(alert)}</span>
319
+ <span className="shrink-0">{formatTimestamp(alert.lastSeenAt)}</span>
320
+ </div>
321
+ </button>
322
+ );
323
+ })}
324
+ </div>
325
+ );
326
+ }
327
+
328
+ function AlertEvidenceLinks({
329
+ alert,
330
+ onNavigate,
331
+ }: {
332
+ alert: Alert;
333
+ onNavigate: () => void;
334
+ }): JSX.Element {
335
+ if (alert.evidence.length === 0) {
336
+ return <span className="text-xs text-muted-foreground">No linked evidence</span>;
337
+ }
338
+
339
+ return (
340
+ <div className="flex flex-wrap gap-2">
341
+ {alert.evidence.map((link) => {
342
+ if (link.href === null) {
343
+ return (
344
+ <Badge key={`${link.kind}:${link.id}`} variant="outline">
345
+ {link.label}
346
+ </Badge>
347
+ );
348
+ }
349
+ return (
350
+ <Badge key={`${link.kind}:${link.id}`} variant="outline" asChild>
351
+ <a href={link.href} onClick={onNavigate}>
352
+ {link.label}
353
+ </a>
354
+ </Badge>
355
+ );
356
+ })}
357
+ </div>
358
+ );
359
+ }
360
+
361
+ function AlertDetails({
362
+ alert,
363
+ onNavigate,
364
+ }: {
365
+ alert: Alert | null;
366
+ onNavigate: () => void;
367
+ }): JSX.Element {
368
+ if (alert === null) {
369
+ return (
370
+ <div className="flex min-h-56 items-center justify-center rounded-md border border-dashed border-border px-4 text-center text-xs text-muted-foreground">
371
+ Select an alert to inspect its evidence.
372
+ </div>
373
+ );
374
+ }
375
+
376
+ return (
377
+ <div className="min-h-0 overflow-y-auto rounded-md border border-border bg-background/70">
378
+ <div className={cn("border-b px-4 py-3", severityAccent(alert.severity))}>
379
+ <div className="flex items-start justify-between gap-3">
380
+ <div className="min-w-0">
381
+ <div className="flex flex-wrap items-center gap-2">
382
+ <Badge variant="outline">{severityLabel(alert.severity)}</Badge>
383
+ <Badge variant="outline">{sourceLabel(alert.source)}</Badge>
384
+ <Badge variant="outline">{categoryLabel(alert.category)}</Badge>
385
+ </div>
386
+ <h3 className="mt-3 text-base font-semibold">{alert.title}</h3>
387
+ <p className="mt-1 text-sm opacity-85">{alert.message}</p>
388
+ </div>
389
+ {alert.count > 1 ? (
390
+ <Badge variant="outline" className="shrink-0">
391
+ {formatCount(alert.count)} hits
392
+ </Badge>
393
+ ) : null}
394
+ </div>
395
+ </div>
396
+
397
+ <div className="space-y-4 p-4">
398
+ <div className="grid gap-2 text-xs sm:grid-cols-2">
399
+ <DetailRow label="Target" value={providerModel(alert)} />
400
+ <DetailRow label="Last seen" value={formatTimestamp(alert.lastSeenAt)} />
401
+ <DetailRow label="First seen" value={formatTimestamp(alert.firstSeenAt)} />
402
+ <DetailRow label="Protocol" value={alert.apiFormat ?? "n/a"} />
403
+ </div>
404
+
405
+ <div>
406
+ <div className="mb-2 text-xs font-semibold text-muted-foreground">Evidence</div>
407
+ <AlertEvidenceLinks alert={alert} onNavigate={onNavigate} />
408
+ </div>
409
+
410
+ <div className="grid gap-2 text-xs sm:grid-cols-2">
411
+ <DetailRow
412
+ label="Logs"
413
+ value={alert.logIds.length === 0 ? "n/a" : formatCount(alert.logIds.length)}
414
+ />
415
+ <DetailRow
416
+ label="Sessions"
417
+ value={alert.sessionIds.length === 0 ? "n/a" : formatCount(alert.sessionIds.length)}
418
+ />
419
+ <DetailRow
420
+ label="Runs"
421
+ value={alert.runIds.length === 0 ? "n/a" : formatCount(alert.runIds.length)}
422
+ />
423
+ <DetailRow
424
+ label="Groups"
425
+ value={alert.groupIds.length === 0 ? "n/a" : formatCount(alert.groupIds.length)}
426
+ />
427
+ </div>
428
+ </div>
429
+ </div>
430
+ );
431
+ }
432
+
433
+ function DetailRow({ label, value }: { label: string; value: string }): JSX.Element {
434
+ return (
435
+ <div className="rounded-md border border-border bg-muted/30 px-3 py-2">
436
+ <div className="text-[10px] font-semibold uppercase text-muted-foreground">{label}</div>
437
+ <div className="mt-1 truncate font-mono text-[11px] text-foreground">{value}</div>
438
+ </div>
439
+ );
440
+ }
441
+
442
+ export function AlertsDialog(): JSX.Element {
443
+ const [open, setOpen] = useState(false);
444
+ const [severityFilter, setSeverityFilter] = useState<SeverityFilter>("all");
445
+ const [categoryFilter, setCategoryFilter] = useState<CategoryFilter>("all");
446
+ const [selectedAlertId, setSelectedAlertId] = useState<string | null>(null);
447
+ const { summary, isLoading: summaryLoading } = useAlertSummary();
448
+ const filters = useMemo(
449
+ () => filtersFrom(severityFilter, categoryFilter),
450
+ [categoryFilter, severityFilter],
451
+ );
452
+ const { alerts, total, isLoading, error, mutate } = useAlerts(filters, open);
453
+ const selectedAlert = useMemo(
454
+ () => selectedAlertFrom(alerts, selectedAlertId),
455
+ [alerts, selectedAlertId],
456
+ );
457
+
458
+ useEffect(() => {
459
+ if (!open) return;
460
+ const selectedExists =
461
+ selectedAlertId !== null && alerts.some((alert) => alert.id === selectedAlertId);
462
+ if (selectedExists) return;
463
+ setSelectedAlertId(alerts[0]?.id ?? null);
464
+ }, [alerts, open, selectedAlertId]);
465
+
466
+ const totalCount = summary?.total ?? 0;
467
+ const criticalCount = summary?.critical ?? 0;
468
+ const warningCount = summary?.warning ?? 0;
469
+ const noticeCount = summary?.notice ?? 0;
470
+ const triggerTone =
471
+ criticalCount > 0 ? "bg-red-500" : warningCount > 0 ? "bg-amber-400" : "bg-sky-400";
472
+
473
+ return (
474
+ <Dialog open={open} onOpenChange={setOpen}>
475
+ <DialogTrigger asChild>
476
+ <Button
477
+ type="button"
478
+ variant="ghost"
479
+ size="icon"
480
+ className="relative size-8"
481
+ aria-label="Alerts"
482
+ title="Alerts"
483
+ >
484
+ <Siren className="size-4" />
485
+ {summaryLoading ? (
486
+ <Loader2 className="absolute -right-0.5 -top-0.5 size-3 animate-spin text-muted-foreground" />
487
+ ) : totalCount > 0 ? (
488
+ <span
489
+ className={cn(
490
+ "absolute -right-1 -top-1 min-w-4 rounded-full px-1 text-[10px] font-semibold leading-4 text-white",
491
+ triggerTone,
492
+ )}
493
+ >
494
+ {formatCount(totalCount)}
495
+ </span>
496
+ ) : null}
497
+ <span className="sr-only">Alerts</span>
498
+ </Button>
499
+ </DialogTrigger>
500
+ <DialogContent className="flex max-h-[86vh] max-w-6xl flex-col overflow-hidden">
501
+ <DialogHeader>
502
+ <DialogTitle className="flex items-center gap-2 pr-8">
503
+ <Siren className="size-5 text-red-300" />
504
+ <span>Alerts</span>
505
+ <Badge variant="outline">{formatCount(totalCount)}</Badge>
506
+ </DialogTitle>
507
+ <DialogDescription className="sr-only">
508
+ Local alert board for captured Inspector evidence.
509
+ </DialogDescription>
510
+ </DialogHeader>
511
+
512
+ <div className="grid gap-2 sm:grid-cols-4">
513
+ <AlertSummaryPill label="Open" count={summary?.open ?? 0} tone="neutral" />
514
+ <AlertSummaryPill label="Critical" count={criticalCount} tone="critical" />
515
+ <AlertSummaryPill label="Warning" count={warningCount} tone="warning" />
516
+ <AlertSummaryPill label="Notice" count={noticeCount} tone="notice" />
517
+ </div>
518
+
519
+ <div className="flex min-h-0 flex-wrap items-center justify-between gap-2 border-y border-border py-3">
520
+ <div className="flex flex-wrap gap-1">
521
+ <SeverityFilterButton
522
+ value="all"
523
+ active={severityFilter === "all"}
524
+ label="All"
525
+ count={summary?.total ?? null}
526
+ onClick={setSeverityFilter}
527
+ />
528
+ <SeverityFilterButton
529
+ value="critical"
530
+ active={severityFilter === "critical"}
531
+ label="Critical"
532
+ count={criticalCount}
533
+ onClick={setSeverityFilter}
534
+ />
535
+ <SeverityFilterButton
536
+ value="warning"
537
+ active={severityFilter === "warning"}
538
+ label="Warning"
539
+ count={warningCount}
540
+ onClick={setSeverityFilter}
541
+ />
542
+ <SeverityFilterButton
543
+ value="notice"
544
+ active={severityFilter === "notice"}
545
+ label="Notice"
546
+ count={noticeCount}
547
+ onClick={setSeverityFilter}
548
+ />
549
+ </div>
550
+ <Button
551
+ type="button"
552
+ variant="outline"
553
+ size="sm"
554
+ className="h-7 shrink-0 px-2"
555
+ onClick={() => void mutate()}
556
+ >
557
+ <RefreshCw className="size-3.5" />
558
+ Refresh
559
+ </Button>
560
+ </div>
561
+
562
+ <div className="flex flex-wrap gap-1">
563
+ <Button
564
+ type="button"
565
+ variant={categoryFilter === "all" ? "secondary" : "ghost"}
566
+ size="sm"
567
+ aria-pressed={categoryFilter === "all"}
568
+ onClick={() => setCategoryFilter("all")}
569
+ className="h-7 px-2"
570
+ >
571
+ All categories
572
+ </Button>
573
+ {ALERT_CATEGORIES.map((category) => {
574
+ const categoryCount =
575
+ summary?.byCategory.find((item) => item.category === category)?.count ?? 0;
576
+ if (categoryCount === 0) return null;
577
+ return (
578
+ <CategoryFilterButton
579
+ key={category}
580
+ category={category}
581
+ active={categoryFilter === category}
582
+ count={categoryCount}
583
+ onClick={setCategoryFilter}
584
+ />
585
+ );
586
+ })}
587
+ </div>
588
+
589
+ <div className="grid min-h-0 flex-1 gap-4 md:grid-cols-[360px_minmax(0,1fr)]">
590
+ <div className="flex min-h-0 flex-col">
591
+ <div className="mb-2 flex items-center justify-between text-xs text-muted-foreground">
592
+ <span>{formatCount(total)} matched</span>
593
+ <span>
594
+ {alerts.length < total ? `${formatCount(alerts.length)} shown` : "Latest"}
595
+ </span>
596
+ </div>
597
+ <AlertList
598
+ alerts={alerts}
599
+ selectedAlertId={selectedAlert?.id ?? null}
600
+ isLoading={isLoading}
601
+ error={error}
602
+ onSelect={setSelectedAlertId}
603
+ />
604
+ </div>
605
+ <AlertDetails alert={selectedAlert} onNavigate={() => setOpen(false)} />
606
+ </div>
607
+ </DialogContent>
608
+ </Dialog>
609
+ );
610
+ }