@tonyclaw/llm-inspector 1.12.0 → 1.13.0
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/index-B0anmGQr.css +1 -0
- package/.output/public/assets/index-H_thmL2_.js +105 -0
- package/.output/public/assets/{main-BYCM7aJx.js → main-C3tLo75s.js} +3 -3
- package/.output/server/_libs/lucide-react.mjs +4 -4
- package/.output/server/_ssr/{index-DhChP_jV.mjs → index-C8VC13EA.mjs} +781 -163
- package/.output/server/_ssr/index.mjs +2 -2
- package/.output/server/_ssr/{router-PZjNwOcw.mjs → router-D5ccnemB.mjs} +2 -2
- package/.output/server/{_tanstack-start-manifest_v-l1kWkG0h.mjs → _tanstack-start-manifest_v-DUbXa1lt.mjs} +1 -1
- package/.output/server/index.mjs +26 -26
- package/package.json +1 -1
- package/src/components/ProxyViewer.tsx +126 -2
- package/src/components/proxy-viewer/CompareDrawer.tsx +388 -0
- package/src/components/proxy-viewer/ConversationGroup.tsx +8 -0
- package/src/components/proxy-viewer/LogEntry.tsx +14 -1
- package/src/components/proxy-viewer/LogEntryHeader.tsx +28 -0
- package/src/components/proxy-viewer/requestDiff.ts +277 -0
- package/.output/public/assets/index-DVgdkDgq.js +0 -105
- package/.output/public/assets/index-DZx2yk8v.css +0 -1
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from "react";
|
|
2
|
+
import type { JSX } from "react";
|
|
3
|
+
import { ChevronRight, X } from "lucide-react";
|
|
4
|
+
import { cn, formatTokens } from "../../lib/utils";
|
|
5
|
+
import type { CapturedLog } from "../../proxy/schemas";
|
|
6
|
+
import { parseRequest } from "../../proxy/schemas";
|
|
7
|
+
import {
|
|
8
|
+
type DiffOp,
|
|
9
|
+
type JsonNode,
|
|
10
|
+
diffTrees,
|
|
11
|
+
normalizeRequest,
|
|
12
|
+
previewNode,
|
|
13
|
+
} from "./requestDiff";
|
|
14
|
+
import { getConversationId } from "./ConversationHeader";
|
|
15
|
+
import { JsonViewerFromString } from "../ui/json-viewer";
|
|
16
|
+
import { Badge } from "../ui/badge";
|
|
17
|
+
|
|
18
|
+
export type CompareDrawerProps = {
|
|
19
|
+
/** Log selected first (shown on the left). */
|
|
20
|
+
left: CapturedLog;
|
|
21
|
+
/** Log selected second (shown on the right). */
|
|
22
|
+
right: CapturedLog;
|
|
23
|
+
onClose: () => void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type EqualOp = Extract<DiffOp, { kind: "equal" }>;
|
|
27
|
+
type AddedOp = Extract<DiffOp, { kind: "added" }>;
|
|
28
|
+
type RemovedOp = Extract<DiffOp, { kind: "removed" }>;
|
|
29
|
+
type ChangedOp = Extract<DiffOp, { kind: "changed" }>;
|
|
30
|
+
|
|
31
|
+
/** Walk the JsonNode tree and pretty-print it back to a JSON string for the
|
|
32
|
+
* expanded-equal-subtree view. The node is a plain object structure so
|
|
33
|
+
* `JSON.stringify` produces correct output. */
|
|
34
|
+
function nodeToJsonString(node: JsonNode, indent = 2): string {
|
|
35
|
+
return JSON.stringify(nodeToJsonValue(node), null, indent);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function nodeToJsonValue(node: JsonNode): unknown {
|
|
39
|
+
switch (node.kind) {
|
|
40
|
+
case "primitive":
|
|
41
|
+
return node.value;
|
|
42
|
+
case "array":
|
|
43
|
+
return node.value.map(nodeToJsonValue);
|
|
44
|
+
case "object": {
|
|
45
|
+
const out: Record<string, unknown> = {};
|
|
46
|
+
for (const [k, v] of Object.entries(node.value)) {
|
|
47
|
+
out[k] = nodeToJsonValue(v);
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The parent path of a JSON path string. E.g. `messages[3].content` →
|
|
55
|
+
* `messages[3]`, `messages[3]` → `messages`, `messages` → `""`. */
|
|
56
|
+
function parentPath(path: string): string {
|
|
57
|
+
if (path === "") return "";
|
|
58
|
+
for (let i = path.length - 1; i >= 0; i--) {
|
|
59
|
+
const ch = path[i];
|
|
60
|
+
if (ch === "." || ch === "[") {
|
|
61
|
+
return path.substring(0, i);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return "";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Group contiguous deep-equal ops (object/array, not primitive) that share
|
|
68
|
+
* a common parent into a single collapsed row, so an unchanged block of N
|
|
69
|
+
* sibling subtrees renders as one row instead of N. Primitive equals
|
|
70
|
+
* always render as their own row (they're 1 line each). */
|
|
71
|
+
type GroupedOp = { kind: "single"; op: DiffOp } | { kind: "equal-run"; ops: EqualOp[] };
|
|
72
|
+
|
|
73
|
+
function isDeepEqual(op: DiffOp): op is EqualOp {
|
|
74
|
+
return op.kind === "equal" && (op.value.kind === "object" || op.value.kind === "array");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function groupContiguousEquals(ops: DiffOp[]): GroupedOp[] {
|
|
78
|
+
const out: GroupedOp[] = [];
|
|
79
|
+
let i = 0;
|
|
80
|
+
while (i < ops.length) {
|
|
81
|
+
const op = ops[i];
|
|
82
|
+
if (op !== undefined && isDeepEqual(op)) {
|
|
83
|
+
const startParent = parentPath(op.path);
|
|
84
|
+
let j = i + 1;
|
|
85
|
+
while (j < ops.length) {
|
|
86
|
+
const next = ops[j];
|
|
87
|
+
if (next === undefined) break;
|
|
88
|
+
if (!isDeepEqual(next)) break;
|
|
89
|
+
if (parentPath(next.path) !== startParent) break;
|
|
90
|
+
j++;
|
|
91
|
+
}
|
|
92
|
+
if (j - i > 1) {
|
|
93
|
+
const equalOps: EqualOp[] = [];
|
|
94
|
+
for (let k = i; k < j; k++) {
|
|
95
|
+
const eop = ops[k];
|
|
96
|
+
if (eop !== undefined && eop.kind === "equal") {
|
|
97
|
+
equalOps.push(eop);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
out.push({ kind: "equal-run", ops: equalOps });
|
|
101
|
+
i = j;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (op !== undefined) {
|
|
106
|
+
out.push({ kind: "single", op });
|
|
107
|
+
}
|
|
108
|
+
i++;
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function EqualRunRow({
|
|
114
|
+
ops,
|
|
115
|
+
expanded,
|
|
116
|
+
onToggle,
|
|
117
|
+
}: {
|
|
118
|
+
ops: EqualOp[];
|
|
119
|
+
expanded: boolean;
|
|
120
|
+
onToggle: () => void;
|
|
121
|
+
}): JSX.Element {
|
|
122
|
+
const first = ops[0];
|
|
123
|
+
const last = ops[ops.length - 1];
|
|
124
|
+
if (first === undefined || last === undefined) {
|
|
125
|
+
return <div className="col-span-3 text-muted-foreground/40 text-xs">—</div>;
|
|
126
|
+
}
|
|
127
|
+
const firstPath = first.path;
|
|
128
|
+
const lastPath = last.path;
|
|
129
|
+
const label = ops.length === 1 ? firstPath : `${firstPath} … ${lastPath}`;
|
|
130
|
+
const summary =
|
|
131
|
+
first.value.kind === "array"
|
|
132
|
+
? `${ops.length} equal arrays`
|
|
133
|
+
: first.value.kind === "object"
|
|
134
|
+
? `${ops.length} equal objects`
|
|
135
|
+
: "equal";
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<div className="col-span-3">
|
|
139
|
+
<button
|
|
140
|
+
type="button"
|
|
141
|
+
onClick={onToggle}
|
|
142
|
+
className="w-full text-left flex items-center gap-2 px-2 py-1 text-xs text-muted-foreground hover:bg-muted/40 rounded cursor-pointer"
|
|
143
|
+
>
|
|
144
|
+
<ChevronRight
|
|
145
|
+
className={cn("size-3 transition-transform shrink-0", expanded && "rotate-90")}
|
|
146
|
+
/>
|
|
147
|
+
<span className="font-mono truncate flex-1">{label}</span>
|
|
148
|
+
<span className="text-muted-foreground/60 shrink-0">({summary})</span>
|
|
149
|
+
</button>
|
|
150
|
+
{expanded && (
|
|
151
|
+
<div className="ml-5 mt-1 mb-2 space-y-2">
|
|
152
|
+
{ops.map((op) => (
|
|
153
|
+
<div key={op.path} className="border border-border/50 rounded p-2 bg-muted/20">
|
|
154
|
+
<div className="font-mono text-xs text-muted-foreground mb-1">{op.path}</div>
|
|
155
|
+
<JsonViewerFromString text={nodeToJsonString(op.value)} defaultExpandDepth={2} />
|
|
156
|
+
</div>
|
|
157
|
+
))}
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function AddOrRemoveRow({
|
|
165
|
+
op,
|
|
166
|
+
kind,
|
|
167
|
+
}: {
|
|
168
|
+
op: AddedOp | RemovedOp;
|
|
169
|
+
kind: "added" | "removed";
|
|
170
|
+
}): JSX.Element {
|
|
171
|
+
const accent =
|
|
172
|
+
kind === "added"
|
|
173
|
+
? "border-l-2 border-l-emerald-400/70 bg-emerald-500/5"
|
|
174
|
+
: "border-l-2 border-l-rose-400/70 bg-rose-500/5";
|
|
175
|
+
return (
|
|
176
|
+
<div className={cn("col-span-3 px-2 py-1 rounded text-xs", accent)}>
|
|
177
|
+
<div className="font-mono text-xs text-muted-foreground mb-0.5">{op.path}</div>
|
|
178
|
+
<div className="font-mono break-all">
|
|
179
|
+
{kind === "added" ? (
|
|
180
|
+
<span className="text-emerald-300/90">+ {previewNode(op.value, 400)}</span>
|
|
181
|
+
) : (
|
|
182
|
+
<span className="text-rose-300/90 line-through">- {previewNode(op.value, 400)}</span>
|
|
183
|
+
)}
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function ChangedRow({ op }: { op: ChangedOp }): JSX.Element {
|
|
190
|
+
return (
|
|
191
|
+
<div className="col-span-3 px-2 py-1 rounded text-xs border-l-2 border-l-amber-400/70 bg-amber-500/5">
|
|
192
|
+
<div className="font-mono text-xs text-muted-foreground mb-1">{op.path}</div>
|
|
193
|
+
<div className="grid grid-cols-2 gap-2">
|
|
194
|
+
<div className="font-mono text-rose-300/90 break-all line-through">
|
|
195
|
+
{previewNode(op.left, 400)}
|
|
196
|
+
</div>
|
|
197
|
+
<div className="font-mono text-emerald-300/90 break-all">{previewNode(op.right, 400)}</div>
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function SideSummary({ log, side }: { log: CapturedLog; side: "left" | "right" }): JSX.Element {
|
|
204
|
+
const conversationId = getConversationId(log);
|
|
205
|
+
return (
|
|
206
|
+
<div className="flex-1 min-w-0 space-y-1 text-xs">
|
|
207
|
+
<div className="flex items-center gap-2">
|
|
208
|
+
<Badge
|
|
209
|
+
variant="outline"
|
|
210
|
+
className={cn(
|
|
211
|
+
"text-[10px] px-1.5 py-0 h-5 font-mono shrink-0",
|
|
212
|
+
side === "left"
|
|
213
|
+
? "border-rose-500/40 text-rose-400"
|
|
214
|
+
: "border-emerald-500/40 text-emerald-400",
|
|
215
|
+
)}
|
|
216
|
+
>
|
|
217
|
+
{side === "left" ? "← Left" : "Right →"}
|
|
218
|
+
</Badge>
|
|
219
|
+
<span className="font-mono text-blue-400/80">#{log.id}</span>
|
|
220
|
+
{log.model !== null && (
|
|
221
|
+
<span className="font-mono text-muted-foreground truncate">{log.model}</span>
|
|
222
|
+
)}
|
|
223
|
+
</div>
|
|
224
|
+
<div className="flex items-center gap-3 text-muted-foreground font-mono">
|
|
225
|
+
{log.cacheCreationInputTokens !== null && log.cacheCreationInputTokens > 0 && (
|
|
226
|
+
<span className="text-emerald-400">
|
|
227
|
+
Cache +{formatTokens(log.cacheCreationInputTokens)}
|
|
228
|
+
</span>
|
|
229
|
+
)}
|
|
230
|
+
{log.cacheReadInputTokens !== null && log.cacheReadInputTokens > 0 && (
|
|
231
|
+
<span className="text-purple-400">Cache ~{formatTokens(log.cacheReadInputTokens)}</span>
|
|
232
|
+
)}
|
|
233
|
+
<span className="truncate" title={log.timestamp}>
|
|
234
|
+
{log.timestamp}
|
|
235
|
+
</span>
|
|
236
|
+
</div>
|
|
237
|
+
<div className="text-muted-foreground/70 font-mono truncate" title={conversationId}>
|
|
238
|
+
session: {conversationId}
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function CompareDrawer({ left, right, onClose }: CompareDrawerProps): JSX.Element {
|
|
245
|
+
// Memoize the diff so re-renders (e.g. parent re-renders) don't recompute.
|
|
246
|
+
const ops = useMemo<DiffOp[]>(() => {
|
|
247
|
+
const l = normalizeRequest(parseRequest(left.rawRequestBody) ?? left.rawRequestBody);
|
|
248
|
+
const r = normalizeRequest(parseRequest(right.rawRequestBody) ?? right.rawRequestBody);
|
|
249
|
+
return diffTrees(l, r);
|
|
250
|
+
}, [left.rawRequestBody, right.rawRequestBody]);
|
|
251
|
+
|
|
252
|
+
const grouped = useMemo(() => groupContiguousEquals(ops), [ops]);
|
|
253
|
+
|
|
254
|
+
// Track which collapsed equal runs are expanded.
|
|
255
|
+
const [expandedRuns, setExpandedRuns] = useState<Set<number>>(new Set());
|
|
256
|
+
const toggleRun = (idx: number) => {
|
|
257
|
+
setExpandedRuns((prev) => {
|
|
258
|
+
const next = new Set(prev);
|
|
259
|
+
if (next.has(idx)) next.delete(idx);
|
|
260
|
+
else next.add(idx);
|
|
261
|
+
return next;
|
|
262
|
+
});
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
// Esc keybinding + body scroll lock while the drawer is open.
|
|
266
|
+
useEffect(() => {
|
|
267
|
+
const onKey = (e: KeyboardEvent) => {
|
|
268
|
+
if (e.key === "Escape") onClose();
|
|
269
|
+
};
|
|
270
|
+
document.addEventListener("keydown", onKey);
|
|
271
|
+
const prevOverflow = document.body.style.overflow;
|
|
272
|
+
document.body.style.overflow = "hidden";
|
|
273
|
+
return () => {
|
|
274
|
+
document.removeEventListener("keydown", onKey);
|
|
275
|
+
document.body.style.overflow = prevOverflow;
|
|
276
|
+
};
|
|
277
|
+
}, [onClose]);
|
|
278
|
+
|
|
279
|
+
const sameSession = getConversationId(left) === getConversationId(right);
|
|
280
|
+
const allEqual = ops.length === 1 && ops[0]?.kind === "equal";
|
|
281
|
+
|
|
282
|
+
return (
|
|
283
|
+
<div
|
|
284
|
+
className="fixed inset-0 z-50 flex justify-end"
|
|
285
|
+
role="dialog"
|
|
286
|
+
aria-modal="true"
|
|
287
|
+
aria-label="Compare two log requests"
|
|
288
|
+
>
|
|
289
|
+
{/* Backdrop */}
|
|
290
|
+
<button
|
|
291
|
+
type="button"
|
|
292
|
+
onClick={onClose}
|
|
293
|
+
aria-label="Close compare drawer"
|
|
294
|
+
className="absolute inset-0 bg-black/40 cursor-default"
|
|
295
|
+
tabIndex={-1}
|
|
296
|
+
/>
|
|
297
|
+
|
|
298
|
+
{/* Drawer panel */}
|
|
299
|
+
<div
|
|
300
|
+
className={cn(
|
|
301
|
+
"relative bg-background border-l border-border shadow-xl",
|
|
302
|
+
"w-full md:w-[70vw] max-w-[1100px] flex flex-col h-full",
|
|
303
|
+
)}
|
|
304
|
+
onClick={(e) => e.stopPropagation()}
|
|
305
|
+
onKeyDown={(e) => e.stopPropagation()}
|
|
306
|
+
>
|
|
307
|
+
{/* Header */}
|
|
308
|
+
<div className="flex items-start gap-4 px-4 py-3 border-b border-border">
|
|
309
|
+
<div className="flex-1 flex gap-4 min-w-0">
|
|
310
|
+
<SideSummary log={left} side="left" />
|
|
311
|
+
<SideSummary log={right} side="right" />
|
|
312
|
+
</div>
|
|
313
|
+
<button
|
|
314
|
+
type="button"
|
|
315
|
+
onClick={onClose}
|
|
316
|
+
aria-label="Close"
|
|
317
|
+
className="shrink-0 p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted cursor-pointer"
|
|
318
|
+
>
|
|
319
|
+
<X className="size-4" />
|
|
320
|
+
</button>
|
|
321
|
+
</div>
|
|
322
|
+
|
|
323
|
+
{!sameSession && (
|
|
324
|
+
<div className="px-4 py-1.5 text-xs text-amber-400 bg-amber-500/10 border-b border-border">
|
|
325
|
+
Heads up: the two selected logs are from different sessions.
|
|
326
|
+
</div>
|
|
327
|
+
)}
|
|
328
|
+
|
|
329
|
+
{/* Body: path-aligned two-pane diff */}
|
|
330
|
+
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
331
|
+
{allEqual ? (
|
|
332
|
+
<div className="px-4 py-12 text-center text-muted-foreground text-sm">
|
|
333
|
+
The two Request payloads are identical.
|
|
334
|
+
</div>
|
|
335
|
+
) : (
|
|
336
|
+
<div className="grid grid-cols-[200px_1fr_1fr] gap-x-2 gap-y-0.5 px-3 py-2 text-xs">
|
|
337
|
+
{/* Column headers */}
|
|
338
|
+
<div className="col-span-3 grid grid-cols-[200px_1fr_1fr] gap-x-2 pb-2 mb-2 border-b border-border text-[10px] uppercase tracking-wider text-muted-foreground">
|
|
339
|
+
<span>Path</span>
|
|
340
|
+
<span>Left (Log #{left.id})</span>
|
|
341
|
+
<span>Right (Log #{right.id})</span>
|
|
342
|
+
</div>
|
|
343
|
+
|
|
344
|
+
{grouped.map((g, i) => {
|
|
345
|
+
if (g.kind === "equal-run") {
|
|
346
|
+
return (
|
|
347
|
+
<EqualRunRow
|
|
348
|
+
key={`r${i}`}
|
|
349
|
+
ops={g.ops}
|
|
350
|
+
expanded={expandedRuns.has(i)}
|
|
351
|
+
onToggle={() => toggleRun(i)}
|
|
352
|
+
/>
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
const op = g.op;
|
|
356
|
+
if (op.kind === "equal") {
|
|
357
|
+
return (
|
|
358
|
+
<div
|
|
359
|
+
key={`e${i}`}
|
|
360
|
+
className="col-span-3 grid grid-cols-[200px_1fr_1fr] gap-x-2 px-2 py-0.5 text-muted-foreground"
|
|
361
|
+
>
|
|
362
|
+
<span className="font-mono text-xs truncate" title={op.path}>
|
|
363
|
+
{op.path}
|
|
364
|
+
</span>
|
|
365
|
+
<span className="font-mono text-xs break-all opacity-60">
|
|
366
|
+
{previewNode(op.value, 200)}
|
|
367
|
+
</span>
|
|
368
|
+
<span className="font-mono text-xs break-all opacity-60">
|
|
369
|
+
{previewNode(op.value, 200)}
|
|
370
|
+
</span>
|
|
371
|
+
</div>
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
if (op.kind === "added") {
|
|
375
|
+
return <AddOrRemoveRow key={`a${i}`} op={op} kind="added" />;
|
|
376
|
+
}
|
|
377
|
+
if (op.kind === "removed") {
|
|
378
|
+
return <AddOrRemoveRow key={`r${i}`} op={op} kind="removed" />;
|
|
379
|
+
}
|
|
380
|
+
return <ChangedRow key={`c${i}`} op={op} />;
|
|
381
|
+
})}
|
|
382
|
+
</div>
|
|
383
|
+
)}
|
|
384
|
+
</div>
|
|
385
|
+
</div>
|
|
386
|
+
</div>
|
|
387
|
+
);
|
|
388
|
+
}
|
|
@@ -21,6 +21,10 @@ export type ConversationGroupProps = {
|
|
|
21
21
|
* across the whole viewer. Each `LogEntry` looks up its own entry.
|
|
22
22
|
*/
|
|
23
23
|
cacheTrends?: Map<number, CacheTrendEntry>;
|
|
24
|
+
/** Set of log ids currently marked for comparison. Forwarded to each `LogEntry`. */
|
|
25
|
+
selectedSet: Set<number>;
|
|
26
|
+
/** Toggle a log in/out of the comparison selection. */
|
|
27
|
+
onToggleSelect: (logId: number) => void;
|
|
24
28
|
};
|
|
25
29
|
|
|
26
30
|
function computeStats(logs: CapturedLog[]): {
|
|
@@ -41,6 +45,8 @@ export const ConversationGroup = memo(function ({
|
|
|
41
45
|
viewMode = "simple",
|
|
42
46
|
strip,
|
|
43
47
|
cacheTrends,
|
|
48
|
+
selectedSet,
|
|
49
|
+
onToggleSelect,
|
|
44
50
|
}: ConversationGroupProps): JSX.Element {
|
|
45
51
|
const [expanded, setExpanded] = useState(false);
|
|
46
52
|
|
|
@@ -81,6 +87,8 @@ export const ConversationGroup = memo(function ({
|
|
|
81
87
|
suppressApiFormatBadge={!mixed}
|
|
82
88
|
strip={strip}
|
|
83
89
|
cacheTrend={cacheTrends?.get(log.id) ?? null}
|
|
90
|
+
isSelected={selectedSet.has(log.id)}
|
|
91
|
+
onToggleSelect={onToggleSelect}
|
|
84
92
|
/>
|
|
85
93
|
))}
|
|
86
94
|
</div>
|
|
@@ -31,6 +31,10 @@ export type LogEntryProps = {
|
|
|
31
31
|
* `null` (or absent) means the header should render with no arrows.
|
|
32
32
|
*/
|
|
33
33
|
cacheTrend?: CacheTrendEntry | null;
|
|
34
|
+
/** Whether this log is currently marked for comparison. */
|
|
35
|
+
isSelected?: boolean;
|
|
36
|
+
/** Toggle this log in/out of the comparison selection. */
|
|
37
|
+
onToggleSelect?: (logId: number) => void;
|
|
34
38
|
};
|
|
35
39
|
|
|
36
40
|
/**
|
|
@@ -138,6 +142,8 @@ export const LogEntry = memo(function ({
|
|
|
138
142
|
suppressApiFormatBadge = false,
|
|
139
143
|
strip,
|
|
140
144
|
cacheTrend = null,
|
|
145
|
+
isSelected = false,
|
|
146
|
+
onToggleSelect,
|
|
141
147
|
}: LogEntryProps): JSX.Element {
|
|
142
148
|
const [expanded, setExpanded] = useState<boolean>(false);
|
|
143
149
|
const [requestCopied, setRequestCopied] = useState<boolean>(false);
|
|
@@ -193,7 +199,12 @@ export const LogEntry = memo(function ({
|
|
|
193
199
|
|
|
194
200
|
return (
|
|
195
201
|
<>
|
|
196
|
-
<div
|
|
202
|
+
<div
|
|
203
|
+
className={cn(
|
|
204
|
+
"border border-border rounded-lg mb-3 overflow-hidden",
|
|
205
|
+
isSelected && "border-l-2 border-l-amber-400",
|
|
206
|
+
)}
|
|
207
|
+
>
|
|
197
208
|
<LogEntryHeader
|
|
198
209
|
log={log}
|
|
199
210
|
parsedRequest={parsedRequest}
|
|
@@ -201,6 +212,8 @@ export const LogEntry = memo(function ({
|
|
|
201
212
|
onToggle={() => setExpanded(!expanded)}
|
|
202
213
|
suppressApiFormatBadge={suppressApiFormatBadge}
|
|
203
214
|
cacheTrend={cacheTrend}
|
|
215
|
+
isSelected={isSelected}
|
|
216
|
+
onToggleSelect={onToggleSelect}
|
|
204
217
|
/>
|
|
205
218
|
|
|
206
219
|
{expanded && (
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ArrowDown,
|
|
3
3
|
ArrowUp,
|
|
4
|
+
Check,
|
|
4
5
|
ChevronDown,
|
|
5
6
|
ChevronRight,
|
|
6
7
|
Clock,
|
|
@@ -66,6 +67,10 @@ export type LogEntryHeaderProps = {
|
|
|
66
67
|
* the corresponding cache span renders as it did before — no arrow.
|
|
67
68
|
*/
|
|
68
69
|
cacheTrend?: { creation: CacheTrend | null; read: CacheTrend | null } | null;
|
|
70
|
+
/** Whether this log is currently marked for comparison. */
|
|
71
|
+
isSelected?: boolean;
|
|
72
|
+
/** Toggle this log in/out of the comparison selection. */
|
|
73
|
+
onToggleSelect?: (logId: number) => void;
|
|
69
74
|
};
|
|
70
75
|
|
|
71
76
|
export const LogEntryHeader = memo(function ({
|
|
@@ -75,6 +80,8 @@ export const LogEntryHeader = memo(function ({
|
|
|
75
80
|
onToggle,
|
|
76
81
|
suppressApiFormatBadge = false,
|
|
77
82
|
cacheTrend = null,
|
|
83
|
+
isSelected = false,
|
|
84
|
+
onToggleSelect,
|
|
78
85
|
}: LogEntryHeaderProps): JSX.Element {
|
|
79
86
|
const statusCategory = getStatusCategory(log.responseStatus);
|
|
80
87
|
|
|
@@ -104,6 +111,27 @@ export const LogEntryHeader = memo(function ({
|
|
|
104
111
|
}
|
|
105
112
|
}}
|
|
106
113
|
>
|
|
114
|
+
{/* Selection checkbox (for log-request comparison) */}
|
|
115
|
+
{onToggleSelect !== undefined && (
|
|
116
|
+
<button
|
|
117
|
+
type="button"
|
|
118
|
+
onClick={(e) => {
|
|
119
|
+
e.stopPropagation();
|
|
120
|
+
onToggleSelect(log.id);
|
|
121
|
+
}}
|
|
122
|
+
aria-label={isSelected ? "Deselect for comparison" : "Select for comparison"}
|
|
123
|
+
aria-pressed={isSelected}
|
|
124
|
+
className={cn(
|
|
125
|
+
"shrink-0 size-4 rounded-sm border flex items-center justify-center transition-colors cursor-pointer",
|
|
126
|
+
isSelected
|
|
127
|
+
? "bg-amber-400 border-amber-400 text-amber-950"
|
|
128
|
+
: "border-muted-foreground/40 hover:border-amber-400 hover:bg-amber-400/10",
|
|
129
|
+
)}
|
|
130
|
+
>
|
|
131
|
+
{isSelected && <Check className="size-3" strokeWidth={3} />}
|
|
132
|
+
</button>
|
|
133
|
+
)}
|
|
134
|
+
|
|
107
135
|
{/* Request ID */}
|
|
108
136
|
<span className="text-blue-400/80 font-mono text-xs font-semibold tabular-nums shrink-0">
|
|
109
137
|
#{log.id}
|