@tonyclaw/agent-inspector 3.0.7 → 3.0.8

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 (66) hide show
  1. package/.output/cli.js +245 -171
  2. package/.output/nitro.json +1 -1
  3. package/.output/public/assets/{CompareDrawer-C0Kbsm-Q.js → CompareDrawer-Bth_Ywyt.js} +1 -1
  4. package/.output/public/assets/ProxyViewerContainer-C73EU9Jq.js +106 -0
  5. package/.output/public/assets/ReplayDialog-C2YYAuTe.js +1 -0
  6. package/.output/public/assets/{RequestAnatomy-fa0JXy5p.js → RequestAnatomy-8k9dAX2j.js} +1 -1
  7. package/.output/public/assets/ResponseView-DEL6JcXm.js +3 -0
  8. package/.output/public/assets/StreamingChunkSequence-DngmoR9j.js +1 -0
  9. package/.output/public/assets/{_sessionId-D5fYH2C0.js → _sessionId-4vk9RMZ5.js} +1 -1
  10. package/.output/public/assets/index-DBEB1rHe.css +1 -0
  11. package/.output/public/assets/{index-BEsqvywM.js → index-DINLvnm9.js} +1 -1
  12. package/.output/public/assets/index-LPIGiUMP.js +1 -0
  13. package/.output/public/assets/{json-viewer-B0QRbMSf.js → json-viewer-CH5dvS2Y.js} +1 -1
  14. package/.output/public/assets/{main-CuKZD7n0.js → main-GWkZn4xy.js} +2 -2
  15. package/.output/server/{_sessionId-DdBTK3qm.mjs → _sessionId-EgsjdkN7.mjs} +3 -3
  16. package/.output/server/_ssr/{CompareDrawer-m82cVZ73.mjs → CompareDrawer-CaM8FYuD.mjs} +2 -2
  17. package/.output/server/_ssr/{ProxyViewerContainer-7geuf78f.mjs → ProxyViewerContainer-EJIogcGd.mjs} +545 -309
  18. package/.output/server/_ssr/{ReplayDialog-B8RlWLLH.mjs → ReplayDialog-BPpJ2-q8.mjs} +58 -23
  19. package/.output/server/_ssr/{RequestAnatomy-BE7pmwpv.mjs → RequestAnatomy-DeQAWnZx.mjs} +2 -2
  20. package/.output/server/_ssr/{ResponseView-D-sDsdQr.mjs → ResponseView-Dshm8iQ7.mjs} +2 -2
  21. package/.output/server/_ssr/{StreamingChunkSequence-DU_hThJr.mjs → StreamingChunkSequence-32yIU2hn.mjs} +30 -20
  22. package/.output/server/_ssr/{index-D0sV8UbE.mjs → index-XRcx9blU.mjs} +2 -2
  23. package/.output/server/_ssr/index.mjs +2 -2
  24. package/.output/server/_ssr/{json-viewer-CaDh1zrw.mjs → json-viewer-C0aX5rqC.mjs} +2 -2
  25. package/.output/server/_ssr/{router-Zh2iZ_u0.mjs → router-BI-Mvxz9.mjs} +441 -172
  26. package/.output/server/_tanstack-start-manifest_v-jKScklL_.mjs +4 -0
  27. package/.output/server/index.mjs +71 -71
  28. package/package.json +1 -1
  29. package/src/assets/IDE/opencode.svg +1 -1
  30. package/src/assets/IDE/xiaomimimo.svg +1 -1
  31. package/src/cli/detect-tools.ts +2 -27
  32. package/src/cli/templates/codex-skill-onboard.ts +1 -1
  33. package/src/cli/templates/skill-onboard.ts +2 -3
  34. package/src/cli.ts +9 -86
  35. package/src/components/ProxyViewer.tsx +55 -8
  36. package/src/components/ProxyViewerContainer.tsx +30 -19
  37. package/src/components/clients/ClientLogo.tsx +22 -2
  38. package/src/components/ecosystem/AgentLabDialog.tsx +204 -3
  39. package/src/components/groups/GroupsDialog.tsx +28 -40
  40. package/src/components/providers/ImportWizardDialog.tsx +51 -101
  41. package/src/components/providers/ProvidersPanel.tsx +82 -68
  42. package/src/components/providers/SettingsDialog.tsx +11 -12
  43. package/src/components/proxy-viewer/AgentTraceSummary.tsx +15 -23
  44. package/src/components/proxy-viewer/ReplayDialog.tsx +65 -22
  45. package/src/components/proxy-viewer/StreamingChunkSequence.tsx +33 -20
  46. package/src/lib/apiClient.ts +43 -10
  47. package/src/lib/ecosystemContract.ts +32 -0
  48. package/src/lib/export-logs.ts +37 -3
  49. package/src/proxy/ecosystemRegistry.ts +244 -0
  50. package/src/proxy/ecosystemTasks.ts +153 -0
  51. package/src/proxy/platformCommands.ts +212 -0
  52. package/src/proxy/socketTracker.ts +84 -143
  53. package/src/routes/api/ecosystem.packages.$packageId.help.ts +16 -0
  54. package/src/routes/api/ecosystem.packages.$packageId.install.ts +16 -0
  55. package/src/routes/api/ecosystem.packages.$packageId.runner-presets.ts +19 -0
  56. package/src/routes/api/ecosystem.packages.$packageId.upgrade.ts +16 -0
  57. package/src/routes/api/ecosystem.packages.ts +1 -248
  58. package/src/routes/api/ecosystem.tasks.$taskId.ts +16 -0
  59. package/src/routes/api/ecosystem.tasks.ts +12 -0
  60. package/.output/public/assets/ProxyViewerContainer-p-rpYyiF.js +0 -106
  61. package/.output/public/assets/ReplayDialog-CM3dhP8v.js +0 -1
  62. package/.output/public/assets/ResponseView-Bq6sC7Ay.js +0 -3
  63. package/.output/public/assets/StreamingChunkSequence-CGOun3Df.js +0 -1
  64. package/.output/public/assets/index-C29Olfzt.js +0 -1
  65. package/.output/public/assets/index-DgANE0r3.css +0 -1
  66. package/.output/server/_tanstack-start-manifest_v-LCE-2AaF.mjs +0 -4
@@ -1,7 +1,7 @@
1
1
  import { useState, useEffect, useCallback, useRef, useMemo, useTransition, type JSX } from "react";
2
2
  import { z } from "zod";
3
3
  import { CapturedLogSchema, type CapturedLog } from "../contracts";
4
- import { fetchJson } from "../lib/apiClient";
4
+ import { fetchJson, fetchJsonWithTimeout } from "../lib/apiClient";
5
5
  import { InspectorGroupsListResponseSchema, type InspectorGroup } from "../lib/groupContract";
6
6
  import { ImportLogsResponseSchema, type ImportLogsResponse } from "../lib/logImportContract";
7
7
  import { useStripConfig } from "../lib/useStripConfig";
@@ -118,6 +118,8 @@ const HASH_HIGHLIGHT_MS = 1800;
118
118
  const SESSION_PAGE_LIMIT = 100;
119
119
  const SESSION_RELOAD_PAGE_LIMIT = 500;
120
120
  const MAX_SESSION_RELOAD_PAGES = 200;
121
+ const SESSION_LOGS_FETCH_TIMEOUT_MS = 20_000;
122
+ const SESSION_MEMBERSHIP_FETCH_TIMEOUT_MS = 10_000;
121
123
 
122
124
  function buildLogsStreamUrl(sessionId: string | undefined): string {
123
125
  const params = new URLSearchParams();
@@ -167,6 +169,18 @@ function buildLogIndex(logs: readonly CapturedLog[]): Map<number, number> {
167
169
  return idx;
168
170
  }
169
171
 
172
+ function fetchSessionLogsPage(
173
+ sessionId: string,
174
+ request: SessionPageRequest,
175
+ limit = SESSION_PAGE_LIMIT,
176
+ ): Promise<LogCursorPage> {
177
+ return fetchJsonWithTimeout(
178
+ buildSessionLogsPageUrl(sessionId, request, limit),
179
+ LogCursorPageSchema,
180
+ SESSION_LOGS_FETCH_TIMEOUT_MS,
181
+ );
182
+ }
183
+
170
184
  export function mergeLogsById(
171
185
  previousLogs: readonly CapturedLog[],
172
186
  incomingLogs: readonly CapturedLog[],
@@ -357,7 +371,7 @@ export function ProxyViewerContainer({
357
371
  setSessionPageLoading(true);
358
372
  dismissToast();
359
373
 
360
- void fetchJson(buildSessionLogsPageUrl(initialSessionId, request), LogCursorPageSchema)
374
+ void fetchSessionLogsPage(initialSessionId, request)
361
375
  .then((page) => {
362
376
  if (sessionPageRequestIdRef.current !== requestId) return;
363
377
  const nextLogs = page.logs;
@@ -388,9 +402,10 @@ export function ProxyViewerContainer({
388
402
 
389
403
  void (async () => {
390
404
  const collected: CapturedLog[] = [];
391
- let page = await fetchJson(
392
- buildSessionLogsPageUrl(sessionId, { kind: "oldest" }, SESSION_RELOAD_PAGE_LIMIT),
393
- LogCursorPageSchema,
405
+ let page = await fetchSessionLogsPage(
406
+ sessionId,
407
+ { kind: "oldest" },
408
+ SESSION_RELOAD_PAGE_LIMIT,
394
409
  );
395
410
  collected.push(...page.logs);
396
411
 
@@ -400,13 +415,10 @@ export function ProxyViewerContainer({
400
415
  page.newestLogId !== null &&
401
416
  loadedPages < MAX_SESSION_RELOAD_PAGES
402
417
  ) {
403
- page = await fetchJson(
404
- buildSessionLogsPageUrl(
405
- sessionId,
406
- { kind: "newer", afterLogId: page.newestLogId },
407
- SESSION_RELOAD_PAGE_LIMIT,
408
- ),
409
- LogCursorPageSchema,
418
+ page = await fetchSessionLogsPage(
419
+ sessionId,
420
+ { kind: "newer", afterLogId: page.newestLogId },
421
+ SESSION_RELOAD_PAGE_LIMIT,
410
422
  );
411
423
  collected.push(...page.logs);
412
424
  loadedPages += 1;
@@ -444,13 +456,12 @@ export function ProxyViewerContainer({
444
456
  }
445
457
 
446
458
  let cancelled = false;
447
- void fetch("/api/groups")
448
- .then(async (response) => {
449
- if (!response.ok) return null;
450
- const raw: unknown = await response.json();
451
- const parsed = InspectorGroupsListResponseSchema.safeParse(raw);
452
- return parsed.success ? parsed.data.groups : null;
453
- })
459
+ void fetchJsonWithTimeout(
460
+ "/api/groups",
461
+ InspectorGroupsListResponseSchema,
462
+ SESSION_MEMBERSHIP_FETCH_TIMEOUT_MS,
463
+ )
464
+ .then((response) => response.groups)
454
465
  .then((groups) => {
455
466
  if (cancelled || groups === null) return;
456
467
  setSessionMemberships(buildSessionMembershipEvidence(groups, initialSessionId));
@@ -67,6 +67,20 @@ const CLIENT_LOGOS: Record<Exclude<ClientApp, "unknown">, string> = {
67
67
  "claude-code": ClaudeCodeLogoSvg,
68
68
  };
69
69
 
70
+ const CLIENT_LOGO_FRAMES: Record<Exclude<ClientApp, "unknown">, string> = {
71
+ codex: "border-cyan-200/20 bg-cyan-300/[0.08]",
72
+ opencode: "border-sky-200/25 bg-sky-300/[0.12]",
73
+ "mimo-code": "border-orange-200/25 bg-orange-400/[0.12]",
74
+ "claude-code": "border-amber-100/20 bg-amber-300/[0.09]",
75
+ };
76
+
77
+ const CLIENT_LOGO_IMAGES: Record<Exclude<ClientApp, "unknown">, string> = {
78
+ codex: "p-0.5",
79
+ opencode: "p-1 drop-shadow-[0_0_8px_rgba(125,211,252,0.35)]",
80
+ "mimo-code": "p-0.5 drop-shadow-[0_0_8px_rgba(255,105,0,0.35)]",
81
+ "claude-code": "p-0.5",
82
+ };
83
+
70
84
  export function ClientLogo({
71
85
  client,
72
86
  className,
@@ -82,13 +96,19 @@ export function ClientLogo({
82
96
  return (
83
97
  <span
84
98
  className={cn(
85
- "border border-border bg-muted/40 inline-flex size-6 shrink-0 items-center justify-center rounded-md p-0.5",
99
+ "inline-flex size-6 shrink-0 items-center justify-center rounded-md border p-0 shadow-sm",
100
+ CLIENT_LOGO_FRAMES[client],
86
101
  className,
87
102
  )}
88
103
  aria-label={label}
89
104
  title={label}
90
105
  >
91
- <img src={logoSrc} alt="" className="size-full object-contain" aria-hidden="true" />
106
+ <img
107
+ src={logoSrc}
108
+ alt=""
109
+ className={cn("size-full object-contain", CLIENT_LOGO_IMAGES[client])}
110
+ aria-hidden="true"
111
+ />
92
112
  </span>
93
113
  );
94
114
  }
@@ -1,10 +1,12 @@
1
- import { type JSX, useMemo, useState } from "react";
1
+ import { type JSX, useEffect, useMemo, useState } from "react";
2
2
  import {
3
3
  Activity,
4
4
  Beaker,
5
5
  Check,
6
6
  Copy,
7
+ Download,
7
8
  FlaskConical,
9
+ Loader2,
8
10
  Network,
9
11
  PackageCheck,
10
12
  RefreshCw,
@@ -15,9 +17,12 @@ import {
15
17
  import useSWR from "swr";
16
18
  import {
17
19
  EcosystemPackagesResponseSchema,
20
+ EcosystemTaskResponseSchema,
18
21
  type EcosystemPackage,
19
22
  type EcosystemPackageState,
20
23
  type EcosystemRunnerPreset,
24
+ type EcosystemTask,
25
+ type EcosystemTaskAction,
21
26
  } from "../../lib/ecosystemContract";
22
27
  import { fetchJson } from "../../lib/apiClient";
23
28
  import { copyTextToClipboard } from "../../lib/clipboard";
@@ -87,6 +92,28 @@ async function fetchEcosystemPackages(url: string) {
87
92
  );
88
93
  }
89
94
 
95
+ async function fetchEcosystemTask(url: string) {
96
+ return fetchJson(
97
+ url,
98
+ EcosystemTaskResponseSchema,
99
+ undefined,
100
+ (response) => `Failed to load ecosystem task: ${String(response.status)}`,
101
+ );
102
+ }
103
+
104
+ async function startEcosystemTask(
105
+ packageId: string,
106
+ action: EcosystemTaskAction,
107
+ ): Promise<EcosystemTask> {
108
+ const response = await fetchJson(
109
+ `/api/ecosystem/packages/${encodeURIComponent(packageId)}/${action}`,
110
+ EcosystemTaskResponseSchema,
111
+ { method: "POST" },
112
+ (result) => `Failed to start ecosystem task: ${String(result.status)}`,
113
+ );
114
+ return response.task;
115
+ }
116
+
90
117
  function stateLabel(state: EcosystemPackageState): string {
91
118
  switch (state) {
92
119
  case "installed":
@@ -127,7 +154,57 @@ function versionLabel(pkg: EcosystemPackage): string {
127
154
  return "not published";
128
155
  }
129
156
 
130
- function PackageStatusCard({ pkg }: { pkg: EcosystemPackage }): JSX.Element {
157
+ function taskActionLabel(action: EcosystemTaskAction): string {
158
+ switch (action) {
159
+ case "install":
160
+ return "Install";
161
+ case "upgrade":
162
+ return "Upgrade";
163
+ case "help":
164
+ return "Help";
165
+ case "runner-presets":
166
+ return "Presets";
167
+ }
168
+ }
169
+
170
+ function taskStatusLabel(task: EcosystemTask): string {
171
+ switch (task.status) {
172
+ case "queued":
173
+ return "Queued";
174
+ case "running":
175
+ return `${taskActionLabel(task.action)} running`;
176
+ case "succeeded":
177
+ return `${taskActionLabel(task.action)} complete`;
178
+ case "failed":
179
+ return `${taskActionLabel(task.action)} failed`;
180
+ }
181
+ }
182
+
183
+ function packagePrimaryAction(pkg: EcosystemPackage): EcosystemTaskAction | null {
184
+ switch (pkg.state) {
185
+ case "available":
186
+ return "install";
187
+ case "update-available":
188
+ return "upgrade";
189
+ case "installed":
190
+ case "planned":
191
+ case "unknown":
192
+ return null;
193
+ }
194
+ }
195
+
196
+ function PackageStatusCard({
197
+ pkg,
198
+ activeTask,
199
+ onStartTask,
200
+ }: {
201
+ pkg: EcosystemPackage;
202
+ activeTask: EcosystemTask | null;
203
+ onStartTask: (packageId: string, action: EcosystemTaskAction) => void;
204
+ }): JSX.Element {
205
+ const primaryAction = packagePrimaryAction(pkg);
206
+ const packageTaskRunning =
207
+ activeTask !== null && activeTask.packageId === pkg.id && activeTask.status === "running";
131
208
  return (
132
209
  <div className="rounded-lg bg-white/[0.025] p-3 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.055)]">
133
210
  <div className="flex flex-wrap items-start justify-between gap-3">
@@ -154,10 +231,87 @@ function PackageStatusCard({ pkg }: { pkg: EcosystemPackage }): JSX.Element {
154
231
  {pkg.installCommand}
155
232
  </span>
156
233
  </div>
234
+ <div className="mt-3 flex flex-wrap items-center gap-2">
235
+ {primaryAction !== null && (
236
+ <Button
237
+ type="button"
238
+ size="sm"
239
+ variant="secondary"
240
+ disabled={packageTaskRunning}
241
+ onClick={() => onStartTask(pkg.id, primaryAction)}
242
+ >
243
+ {packageTaskRunning ? (
244
+ <Loader2 className="size-3.5 animate-spin" />
245
+ ) : (
246
+ <Download className="size-3.5" />
247
+ )}
248
+ {taskActionLabel(primaryAction)}
249
+ </Button>
250
+ )}
251
+ <Button
252
+ type="button"
253
+ size="sm"
254
+ variant="outline"
255
+ disabled={packageTaskRunning || pkg.state === "planned"}
256
+ onClick={() => onStartTask(pkg.id, "help")}
257
+ >
258
+ <Terminal className="size-3.5" />
259
+ Help
260
+ </Button>
261
+ {pkg.id === "eval-harness" && (
262
+ <Button
263
+ type="button"
264
+ size="sm"
265
+ variant="ghost"
266
+ disabled={packageTaskRunning || pkg.state === "planned"}
267
+ onClick={() => onStartTask(pkg.id, "runner-presets")}
268
+ >
269
+ <Beaker className="size-3.5" />
270
+ Presets
271
+ </Button>
272
+ )}
273
+ </div>
157
274
  </div>
158
275
  );
159
276
  }
160
277
 
278
+ function TaskConsole({ task }: { task: EcosystemTask | null }): JSX.Element | null {
279
+ if (task === null) return null;
280
+ const isRunning = task.status === "queued" || task.status === "running";
281
+ return (
282
+ <section className="mt-3 rounded-lg bg-white/[0.025] p-3 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.055)]">
283
+ <div className="flex flex-wrap items-center justify-between gap-3">
284
+ <div className="flex min-w-0 items-center gap-2">
285
+ {isRunning ? (
286
+ <Loader2 className="size-4 animate-spin text-cyan-200/75" />
287
+ ) : (
288
+ <Terminal className="size-4 text-white/55" />
289
+ )}
290
+ <div className="min-w-0">
291
+ <div className="text-sm font-semibold">{taskStatusLabel(task)}</div>
292
+ <div className="break-all font-mono text-[11px] text-white/35">{task.command}</div>
293
+ </div>
294
+ </div>
295
+ <Badge
296
+ variant="outline"
297
+ className={cn(
298
+ "shrink-0",
299
+ task.status === "failed"
300
+ ? "border-red-400/20 bg-red-400/8 text-red-100"
301
+ : "border-cyan-300/20 bg-cyan-300/8 text-cyan-100",
302
+ )}
303
+ >
304
+ {task.status}
305
+ </Badge>
306
+ </div>
307
+ <pre className="mt-3 max-h-56 overflow-auto whitespace-pre-wrap rounded-md bg-black/30 p-3 font-mono text-[11px] leading-5 text-white/60 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.045)]">
308
+ {task.output.trim().length > 0 ? task.output : "Waiting for npm output..."}
309
+ </pre>
310
+ {task.error !== null && <div className="mt-2 text-xs text-red-100">{task.error}</div>}
311
+ </section>
312
+ );
313
+ }
314
+
161
315
  function WorkflowTile({ workflow }: { workflow: WorkflowCard }): JSX.Element {
162
316
  const Icon = workflow.icon;
163
317
  return (
@@ -222,10 +376,25 @@ export function AgentLabDialog({ currentSessionId, logCount }: AgentLabDialogPro
222
376
  const [open, setOpen] = useState(false);
223
377
  const [copied, setCopied] = useState(false);
224
378
  const [copiedPresets, setCopiedPresets] = useState(false);
379
+ const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
380
+ const [taskError, setTaskError] = useState<string | null>(null);
225
381
  const response = useSWR("/api/ecosystem/packages", fetchEcosystemPackages, {
226
382
  revalidateOnFocus: false,
227
383
  revalidateIfStale: false,
228
384
  });
385
+ const activeTaskResponse = useSWR(
386
+ activeTaskId === null ? null : `/api/ecosystem/tasks/${activeTaskId}`,
387
+ fetchEcosystemTask,
388
+ {
389
+ refreshInterval: (taskResponse) => {
390
+ const task = taskResponse?.task;
391
+ return task !== undefined && (task.status === "queued" || task.status === "running")
392
+ ? 900
393
+ : 0;
394
+ },
395
+ revalidateOnFocus: false,
396
+ },
397
+ );
229
398
  const packages = response.data?.packages ?? [];
230
399
  const runnerPresets = response.data?.runnerPresets ?? [];
231
400
  const installedCount = useMemo(
@@ -250,6 +419,26 @@ export function AgentLabDialog({ currentSessionId, logCount }: AgentLabDialogPro
250
419
  });
251
420
  };
252
421
 
422
+ const activeTask = activeTaskResponse.data?.task ?? null;
423
+
424
+ useEffect(() => {
425
+ if (activeTask === null) return;
426
+ if (activeTask.status !== "succeeded") return;
427
+ void response.mutate();
428
+ }, [activeTask, response]);
429
+
430
+ const handleStartTask = (packageId: string, action: EcosystemTaskAction): void => {
431
+ setTaskError(null);
432
+ void startEcosystemTask(packageId, action)
433
+ .then((task) => {
434
+ setActiveTaskId(task.id);
435
+ void activeTaskResponse.mutate({ task }, { revalidate: true });
436
+ })
437
+ .catch((error: unknown) => {
438
+ setTaskError(error instanceof Error ? error.message : "Failed to start ecosystem task");
439
+ });
440
+ };
441
+
253
442
  return (
254
443
  <Dialog open={open} onOpenChange={setOpen}>
255
444
  <DialogTrigger asChild>
@@ -362,7 +551,12 @@ export function AgentLabDialog({ currentSessionId, logCount }: AgentLabDialogPro
362
551
  )}
363
552
  <div className="grid gap-3 md:grid-cols-3">
364
553
  {packages.map((pkg) => (
365
- <PackageStatusCard key={pkg.id} pkg={pkg} />
554
+ <PackageStatusCard
555
+ key={pkg.id}
556
+ pkg={pkg}
557
+ activeTask={activeTask}
558
+ onStartTask={handleStartTask}
559
+ />
366
560
  ))}
367
561
  {response.isLoading &&
368
562
  packages.length === 0 &&
@@ -374,6 +568,13 @@ export function AgentLabDialog({ currentSessionId, logCount }: AgentLabDialogPro
374
568
  ))}
375
569
  </div>
376
570
  </section>
571
+
572
+ {taskError !== null && (
573
+ <div className="mt-3 rounded-lg bg-red-500/8 p-3 text-xs text-red-100 shadow-[inset_0_0_0_1px_rgba(248,113,113,0.16)]">
574
+ {taskError}
575
+ </div>
576
+ )}
577
+ <TaskConsole task={activeTask} />
377
578
  </div>
378
579
  </DialogContent>
379
580
  </Dialog>
@@ -16,7 +16,7 @@ import { Badge } from "../ui/badge";
16
16
  import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "../ui/dialog";
17
17
  import { INSPECTOR_ICON_TRIGGER_CLASS } from "../ui/icon-trigger";
18
18
  import { cn } from "../../lib/utils";
19
- import { parseJsonResponse, readApiError } from "../../lib/apiClient";
19
+ import { fetchJsonWithTimeout } from "../../lib/apiClient";
20
20
  import {
21
21
  DeleteInspectorGroupResponseSchema,
22
22
  DeleteInspectorGroupsResponseSchema,
@@ -53,6 +53,8 @@ type GroupMemberRow = {
53
53
  source: "group" | "export" | "evidence";
54
54
  };
55
55
 
56
+ const GROUP_OPERATION_TIMEOUT_MS = 20_000;
57
+
56
58
  export function groupDeletePath(groupId: string, deleteEvidence: boolean): string {
57
59
  const path = `/api/groups/${encodeURIComponent(groupId)}`;
58
60
  return deleteEvidence ? `${path}?deleteEvidence=1` : path;
@@ -333,19 +335,13 @@ export function GroupsDialog(): JSX.Element {
333
335
  setExportError(null);
334
336
 
335
337
  try {
336
- const response = await fetch(groupsClearPath(false), { method: "DELETE" });
337
- if (!response.ok) {
338
- showToast({
339
- message: await readApiError(
340
- response,
341
- `Clear failed with status ${String(response.status)}`,
342
- ),
343
- tone: "error",
344
- });
345
- return;
346
- }
347
-
348
- const parsed = await parseJsonResponse(response, DeleteInspectorGroupsResponseSchema);
338
+ const parsed = await fetchJsonWithTimeout(
339
+ groupsClearPath(false),
340
+ DeleteInspectorGroupsResponseSchema,
341
+ GROUP_OPERATION_TIMEOUT_MS,
342
+ { method: "DELETE" },
343
+ (response) => `Clear failed with status ${String(response.status)}`,
344
+ );
349
345
  setExportResult(null);
350
346
  setSelectedGroupId(null);
351
347
  await mutate();
@@ -370,20 +366,17 @@ export function GroupsDialog(): JSX.Element {
370
366
  setExportError(null);
371
367
 
372
368
  try {
373
- const response = await fetch(`/api/groups/${encodeURIComponent(group.id)}/evidence`, {
374
- method: "POST",
375
- headers: { "content-type": "application/json" },
376
- body: JSON.stringify({ latestLogLimit: 10 }),
377
- });
378
-
379
- if (!response.ok) {
380
- setExportError(
381
- await readApiError(response, `Export failed with status ${String(response.status)}`),
382
- );
383
- return;
384
- }
385
-
386
- const parsed = await parseJsonResponse(response, GroupEvidenceExportResultSchema);
369
+ const parsed = await fetchJsonWithTimeout(
370
+ `/api/groups/${encodeURIComponent(group.id)}/evidence`,
371
+ GroupEvidenceExportResultSchema,
372
+ GROUP_OPERATION_TIMEOUT_MS,
373
+ {
374
+ method: "POST",
375
+ headers: { "content-type": "application/json" },
376
+ body: JSON.stringify({ latestLogLimit: 10 }),
377
+ },
378
+ (response) => `Export failed with status ${String(response.status)}`,
379
+ );
387
380
  setExportResult(parsed);
388
381
  await mutate();
389
382
  await mutateEvidence();
@@ -424,18 +417,13 @@ export function GroupsDialog(): JSX.Element {
424
417
  setDeleteError(null);
425
418
 
426
419
  try {
427
- const response = await fetch(groupDeletePath(group.id, deleteEvidence), {
428
- method: "DELETE",
429
- });
430
-
431
- if (!response.ok) {
432
- setDeleteError(
433
- await readApiError(response, `Delete failed with status ${String(response.status)}`),
434
- );
435
- return;
436
- }
437
-
438
- await parseJsonResponse(response, DeleteInspectorGroupResponseSchema);
420
+ await fetchJsonWithTimeout(
421
+ groupDeletePath(group.id, deleteEvidence),
422
+ DeleteInspectorGroupResponseSchema,
423
+ GROUP_OPERATION_TIMEOUT_MS,
424
+ { method: "DELETE" },
425
+ (response) => `Delete failed with status ${String(response.status)}`,
426
+ );
439
427
  setExportResult((current) =>
440
428
  current !== null && current.group.id === group.id ? null : current,
441
429
  );