@tonyclaw/agent-inspector 2.1.2 → 2.1.4
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.
- package/.output/nitro.json +1 -1
- package/.output/public/assets/{CompareDrawer-BULPis8W.js → CompareDrawer-BihEPd3v.js} +1 -1
- package/.output/public/assets/ProxyViewerContainer-DYqXb6WW.js +117 -0
- package/.output/public/assets/{ReplayDialog-3ln-D7OS.js → ReplayDialog-DnX-6kY8.js} +1 -1
- package/.output/public/assets/{RequestAnatomy-D8d1xV8O.js → RequestAnatomy-CIkHAxZO.js} +1 -1
- package/.output/public/assets/{ResponseView-CJijikn-.js → ResponseView-BFc8jjZM.js} +1 -1
- package/.output/public/assets/{StreamingChunkSequence-C-RCuOTp.js → StreamingChunkSequence-p8qkpF6K.js} +1 -1
- package/.output/public/assets/_sessionId-Csx4T_y6.js +1 -0
- package/.output/public/assets/index-Bcq8bZoK.css +1 -0
- package/.output/public/assets/index-KMuh-31x.js +1 -0
- package/.output/public/assets/{main-D3pBFhb_.js → main-xlSdu7_I.js} +7 -7
- package/.output/server/_libs/lucide-react.mjs +204 -173
- package/.output/server/_libs/radix-ui__react-dialog.mjs +2 -0
- package/.output/server/{_sessionId-BFMVfrw3.mjs → _sessionId-CwVQgxoK.mjs} +3 -3
- package/.output/server/_ssr/{CompareDrawer-CwNPZgc_.mjs → CompareDrawer-Br9Ec0ah.mjs} +3 -3
- package/.output/server/_ssr/{ProxyViewerContainer-HpLc7F_J.mjs → ProxyViewerContainer-Oe5Tr3C2.mjs} +982 -70
- package/.output/server/_ssr/{ReplayDialog-DBaFhSv6.mjs → ReplayDialog-C1_QsoA-.mjs} +4 -4
- package/.output/server/_ssr/{RequestAnatomy-D0E0e2DT.mjs → RequestAnatomy-cn08QSoz.mjs} +2 -2
- package/.output/server/_ssr/{ResponseView-D2FFSRQ8.mjs → ResponseView-CIqUCCTG.mjs} +3 -3
- package/.output/server/_ssr/{StreamingChunkSequence-Cdu36-rL.mjs → StreamingChunkSequence-0MhVWD2F.mjs} +3 -3
- package/.output/server/_ssr/{index-CtBfmJYM.mjs → index-jpnI5ghp.mjs} +2 -2
- package/.output/server/_ssr/index.mjs +2 -2
- package/.output/server/_ssr/{router-Dzx1sbAQ.mjs → router-SSOn7c09.mjs} +1971 -222
- package/.output/server/_tanstack-start-manifest_v-BCgy_9er.mjs +4 -0
- package/.output/server/index.mjs +62 -62
- package/package.json +2 -1
- package/src/assets/agent-inspector.ico +0 -0
- package/src/assets/favicon.svg +21 -31
- package/src/components/ProxyViewer.tsx +87 -1
- package/src/components/ProxyViewerContainer.tsx +26 -0
- package/src/components/alerts/AlertsDialog.tsx +610 -0
- package/src/components/proxy-viewer/LogEntry.tsx +324 -51
- package/src/components/ui/dialog.tsx +1 -0
- package/src/contracts/index.ts +15 -2
- package/src/contracts/log.ts +18 -0
- package/src/lib/alertContract.ts +79 -0
- package/src/lib/apiClient.ts +39 -0
- package/src/lib/export-logs.ts +47 -9
- package/src/lib/logImportContract.ts +12 -0
- package/src/lib/useAlerts.ts +82 -0
- package/src/lib/useGroupEvidence.ts +6 -2
- package/src/lib/useGroups.ts +6 -2
- package/src/proxy/alerts.ts +664 -0
- package/src/proxy/logBodyChunks.ts +91 -0
- package/src/proxy/logImporter.ts +491 -0
- package/src/proxy/logIndex.ts +63 -2
- package/src/proxy/sqliteLogIndex.ts +612 -0
- package/src/proxy/store.ts +32 -7
- package/src/routes/api/alerts.summary.ts +24 -0
- package/src/routes/api/alerts.ts +52 -0
- package/src/routes/api/logs.$id.body.ts +44 -0
- package/src/routes/api/logs.import.ts +50 -0
- package/src/services/alerts.ts +12 -0
- package/.output/public/assets/ProxyViewerContainer-YxtP1xBj.js +0 -117
- package/.output/public/assets/_sessionId-DGehOTux.js +0 -1
- package/.output/public/assets/index-B-MZ9lQM.css +0 -1
- package/.output/public/assets/index-DhsjXb_K.js +0 -1
- package/.output/server/_tanstack-start-manifest_v-VrssoWVE.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
|
+
}
|