khotan-data 0.1.1 → 0.3.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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { Fragment, useCallback, useEffect, useState } from "react";
4
4
  import { RefreshCw } from "lucide-react";
5
+ import { khotanFetch, ApiErrorState } from "./api-state";
5
6
  import { Badge } from "@/components/ui/badge";
6
7
  import { Button } from "@/components/ui/button";
7
8
  import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -359,7 +360,7 @@ export function KhotanRunsTable({ pageSize = 10 }: { pageSize?: number } = {}) {
359
360
  const [data, setData] = useState<PageResponse<RunLogItem> | null>(null);
360
361
  const [offset, setOffset] = useState(0);
361
362
  const [loading, setLoading] = useState(true);
362
- const [error, setError] = useState<string | null>(null);
363
+ const [error, setError] = useState<unknown>(null);
363
364
  const [refreshKey, setRefreshKey] = useState(0);
364
365
  const [expandedRunId, setExpandedRunId] = useState<string | null>(null);
365
366
  const [streamingEnabled, setStreamingEnabled] = useState(false);
@@ -378,20 +379,16 @@ export function KhotanRunsTable({ pageSize = 10 }: { pageSize?: number } = {}) {
378
379
  setLoading(true);
379
380
  setError(null);
380
381
  try {
381
- const res = await fetch(
382
+ const json = await khotanFetch<PageResponse<RunLogItem>>(
382
383
  `/api/khotan/runs?limit=${String(pageSize)}&offset=${String(offset)}`,
383
384
  );
384
- if (!res.ok) {
385
- throw new Error("Failed to load runs");
386
- }
387
- const json = (await res.json()) as PageResponse<RunLogItem>;
388
385
  if (!cancelled) {
389
386
  setData(json);
390
387
  setLastUpdatedAt(new Date().toISOString());
391
388
  }
392
389
  } catch (err) {
393
390
  if (!cancelled) {
394
- setError(err instanceof Error ? err.message : "Unknown error");
391
+ setError(err);
395
392
  }
396
393
  } finally {
397
394
  if (!cancelled) {
@@ -454,138 +451,144 @@ export function KhotanRunsTable({ pageSize = 10 }: { pageSize?: number } = {}) {
454
451
  </CardHeader>
455
452
  <CardContent className="space-y-4">
456
453
  {error ? (
457
- <div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
458
- {error}
459
- </div>
454
+ <ApiErrorState
455
+ error={error}
456
+ onRetry={() => setRefreshKey((v) => v + 1)}
457
+ compact
458
+ />
460
459
  ) : null}
461
460
 
462
- <Table>
463
- <TableHeader>
464
- <TableRow>
465
- <TableHead>Started</TableHead>
466
- <TableHead>Status</TableHead>
467
- <TableHead>Source</TableHead>
468
- <TableHead>Plug</TableHead>
469
- <TableHead>Run Type</TableHead>
470
- <TableHead>Counts</TableHead>
471
- <TableHead>Workflow</TableHead>
472
- <TableHead />
473
- </TableRow>
474
- </TableHeader>
475
- <TableBody>
476
- {loading ? (
461
+ {error ? null : (
462
+ <Table>
463
+ <TableHeader>
477
464
  <TableRow>
478
- <TableCell
479
- colSpan={8}
480
- className="text-sm text-muted-foreground"
481
- >
482
- Loading runs...
483
- </TableCell>
465
+ <TableHead>Started</TableHead>
466
+ <TableHead>Status</TableHead>
467
+ <TableHead>Source</TableHead>
468
+ <TableHead>Plug</TableHead>
469
+ <TableHead>Run Type</TableHead>
470
+ <TableHead>Counts</TableHead>
471
+ <TableHead>Workflow</TableHead>
472
+ <TableHead />
484
473
  </TableRow>
485
- ) : data?.items.length ? (
486
- data.items.map((item) => (
487
- <Fragment key={item.id}>
488
- <TableRow>
489
- <TableCell className="text-sm text-muted-foreground">
490
- <div>{formatDateTime(item.startedAt)}</div>
491
- <div className="text-xs">
492
- {item.completedAt
493
- ? `completed ${formatDateTime(item.completedAt)}`
494
- : "in progress"}
495
- </div>
496
- </TableCell>
497
- <TableCell>
498
- <Badge variant={statusVariant[item.status]}>
499
- {statusLabel[item.status]}
500
- </Badge>
501
- {item.error ? (
502
- <div
503
- className="mt-1 max-w-56 truncate text-xs text-destructive"
504
- title={item.error}
505
- >
506
- {item.error}
507
- </div>
508
- ) : null}
509
- </TableCell>
510
- <TableCell className="font-medium">
511
- {formatSource(item)}
512
- </TableCell>
513
- <TableCell className="text-muted-foreground">
514
- {item.plugName ?? "-"}
515
- </TableCell>
516
- <TableCell className="font-mono text-xs">
517
- {item.runType}
518
- </TableCell>
519
- <TableCell className="max-w-64 text-xs text-muted-foreground">
520
- {formatCounts(item)}
521
- </TableCell>
522
- <TableCell className="font-mono text-xs text-muted-foreground">
523
- {item.workflowRunId ?? "-"}
524
- </TableCell>
525
- <TableCell className="text-right">
526
- <Button
527
- variant="outline"
528
- size="sm"
529
- onClick={() =>
530
- setExpandedRunId((current) =>
531
- current === item.id ? null : item.id,
532
- )
533
- }
534
- >
535
- {expandedRunId === item.id ? "Hide" : "Details"}
536
- </Button>
537
- </TableCell>
538
- </TableRow>
539
- {expandedRunId === item.id ? (
474
+ </TableHeader>
475
+ <TableBody>
476
+ {loading ? (
477
+ <TableRow>
478
+ <TableCell
479
+ colSpan={8}
480
+ className="text-sm text-muted-foreground"
481
+ >
482
+ Loading runs...
483
+ </TableCell>
484
+ </TableRow>
485
+ ) : data?.items.length ? (
486
+ data.items.map((item) => (
487
+ <Fragment key={item.id}>
540
488
  <TableRow>
541
- <TableCell colSpan={8}>
542
- <RunDetails
543
- run={item}
544
- streamingEnabled={streamingEnabled}
545
- onChanged={() => setRefreshKey((v) => v + 1)}
546
- onStreamInbound={pulseLiveIndicator}
547
- />
489
+ <TableCell className="text-sm text-muted-foreground">
490
+ <div>{formatDateTime(item.startedAt)}</div>
491
+ <div className="text-xs">
492
+ {item.completedAt
493
+ ? `completed ${formatDateTime(item.completedAt)}`
494
+ : "in progress"}
495
+ </div>
496
+ </TableCell>
497
+ <TableCell>
498
+ <Badge variant={statusVariant[item.status]}>
499
+ {statusLabel[item.status]}
500
+ </Badge>
501
+ {item.error ? (
502
+ <div
503
+ className="mt-1 max-w-56 truncate text-xs text-destructive"
504
+ title={item.error}
505
+ >
506
+ {item.error}
507
+ </div>
508
+ ) : null}
509
+ </TableCell>
510
+ <TableCell className="font-medium">
511
+ {formatSource(item)}
512
+ </TableCell>
513
+ <TableCell className="text-muted-foreground">
514
+ {item.plugName ?? "-"}
515
+ </TableCell>
516
+ <TableCell className="font-mono text-xs">
517
+ {item.runType}
518
+ </TableCell>
519
+ <TableCell className="max-w-64 text-xs text-muted-foreground">
520
+ {formatCounts(item)}
521
+ </TableCell>
522
+ <TableCell className="font-mono text-xs text-muted-foreground">
523
+ {item.workflowRunId ?? "-"}
524
+ </TableCell>
525
+ <TableCell className="text-right">
526
+ <Button
527
+ variant="outline"
528
+ size="sm"
529
+ onClick={() =>
530
+ setExpandedRunId((current) =>
531
+ current === item.id ? null : item.id,
532
+ )
533
+ }
534
+ >
535
+ {expandedRunId === item.id ? "Hide" : "Details"}
536
+ </Button>
548
537
  </TableCell>
549
538
  </TableRow>
550
- ) : null}
551
- </Fragment>
552
- ))
553
- ) : (
554
- <TableRow>
555
- <TableCell
556
- colSpan={8}
557
- className="text-sm text-muted-foreground"
558
- >
559
- No runs recorded yet.
560
- </TableCell>
561
- </TableRow>
562
- )}
563
- </TableBody>
564
- </Table>
539
+ {expandedRunId === item.id ? (
540
+ <TableRow>
541
+ <TableCell colSpan={8}>
542
+ <RunDetails
543
+ run={item}
544
+ streamingEnabled={streamingEnabled}
545
+ onChanged={() => setRefreshKey((v) => v + 1)}
546
+ onStreamInbound={pulseLiveIndicator}
547
+ />
548
+ </TableCell>
549
+ </TableRow>
550
+ ) : null}
551
+ </Fragment>
552
+ ))
553
+ ) : (
554
+ <TableRow>
555
+ <TableCell
556
+ colSpan={8}
557
+ className="text-sm text-muted-foreground"
558
+ >
559
+ No runs recorded yet.
560
+ </TableCell>
561
+ </TableRow>
562
+ )}
563
+ </TableBody>
564
+ </Table>
565
+ )}
565
566
 
566
- <div className="flex items-center justify-between gap-3">
567
- <p className="text-sm text-muted-foreground">
568
- Page {Math.floor(offset / pageSize) + 1}
569
- </p>
570
- <div className="flex items-center gap-2">
571
- <Button
572
- variant="outline"
573
- size="sm"
574
- disabled={offset === 0 || loading}
575
- onClick={() => setOffset(Math.max(offset - pageSize, 0))}
576
- >
577
- Previous
578
- </Button>
579
- <Button
580
- variant="outline"
581
- size="sm"
582
- disabled={!data?.page.hasMore || loading}
583
- onClick={() => setOffset(offset + pageSize)}
584
- >
585
- Next
586
- </Button>
567
+ {error ? null : (
568
+ <div className="flex items-center justify-between gap-3">
569
+ <p className="text-sm text-muted-foreground">
570
+ Page {Math.floor(offset / pageSize) + 1}
571
+ </p>
572
+ <div className="flex items-center gap-2">
573
+ <Button
574
+ variant="outline"
575
+ size="sm"
576
+ disabled={offset === 0 || loading}
577
+ onClick={() => setOffset(Math.max(offset - pageSize, 0))}
578
+ >
579
+ Previous
580
+ </Button>
581
+ <Button
582
+ variant="outline"
583
+ size="sm"
584
+ disabled={!data?.page.hasMore || loading}
585
+ onClick={() => setOffset(offset + pageSize)}
586
+ >
587
+ Next
588
+ </Button>
589
+ </div>
587
590
  </div>
588
- </div>
591
+ )}
589
592
  </CardContent>
590
593
  </Card>
591
594
  );
@@ -20,7 +20,7 @@ The Hub scaffolds three components to `src/components/khotan/`:
20
20
 
21
21
  | File | Purpose |
22
22
  |------|---------|
23
- | `hub.tsx` | Main `<KhotanHub />` — plug cards, flow table, enable/disable toggles |
23
+ | `hub.tsx` | Main `<KhotanHub />` — plug cards, flow table, enable/disable toggles, per-flow "Run now" trigger |
24
24
  | `var-panel.tsx` | Variables panel for configuring plug vars |
25
25
  | `wire-panel.tsx` | Webhook subscription management (connect/disconnect) |
26
26
 
@@ -44,6 +44,7 @@ Or use `npx khotan add config-page-1` to scaffold a `/config` page automatically
44
44
 
45
45
  - Lists all registered plugs with status badges (connected/error/idle)
46
46
  - Click a plug to see its flows with enable/disable toggles
47
+ - "Run now" button on each flow row triggers a tracked run via `POST /api/khotan/flows/:id/runs` (uses the browser session, so it passes your `authorize` hook)
47
48
  - VarPanel: configure plug variables (stored encrypted via `KHOTAN_SECRET`)
48
49
  - WirePanel: manage webhook subscriptions (requires wires configured on plug)
49
50
  - Debug button on each plug card (visible when `KHOTAN_DEBUG=1`)
@@ -90,9 +90,48 @@ The schema command auto-detects your Drizzle schema directory, updates `drizzle.
90
90
  |----------|----------|---------|
91
91
  | `DATABASE_URL` | Yes | Postgres connection (used by Drizzle) |
92
92
  | `KHOTAN_SECRET` | For variables | AES-256-GCM key for encrypting plug vars |
93
- | `KHOTAN_DEBUG` | For debugging | Enables `/debug/*` routes and the `plug` CLI (`probe` alias) |
93
+ | `KHOTAN_DEBUG` | For debugging | Enables `/debug/*` routes and the `plug` CLI (`probe` alias). Automatically disabled when `NODE_ENV=production` |
94
94
  | `KHOTAN_WEBHOOK_URL` | For webhooks | Public URL for wire callbacks |
95
- | `CRON_SECRET` | For production cron | Protects the built-in `/api/khotan/cron` dispatcher route |
95
+ | `CRON_SECRET` | For production cron | Protects the built-in `/api/khotan/cron` dispatcher route. The route fails closed in production when this is unset |
96
+
97
+ ## Securing the Management API
98
+
99
+ The management API (`/api/khotan/*`) and the Hub dashboard expose plug
100
+ credentials and operational controls. **They are public unless you gate them.**
101
+
102
+ Pass an `authorize` hook to `khotan({ ... })`. It receives the raw `Request` and
103
+ returns `true` to allow the request or `false` to reject it with `401`. It
104
+ composes directly with session libraries like better-auth:
105
+
106
+ ```typescript
107
+ import { khotan, drizzleAdapter } from "khotan-data/factory";
108
+ import { auth } from "@/lib/auth";
109
+ import { db } from "@/db";
110
+
111
+ const khotanData = khotan({
112
+ adapter: drizzleAdapter(db),
113
+ authorize: async (request) => {
114
+ const session = await auth.api.getSession({ headers: request.headers });
115
+ return Boolean(session?.user); // or: session?.user?.role === "admin"
116
+ },
117
+ plugs: [/* ... */],
118
+ });
119
+ ```
120
+
121
+ Notes:
122
+ - `authorize` is **not** a replacement for `KHOTAN_SECRET` — that key only
123
+ encrypts credentials at rest, it does not authenticate requests. Conversely,
124
+ `KHOTAN_SECRET` is **not** an HTTP credential: do not send it as a Bearer
125
+ token. Management routes are gated solely by `authorize` (plus the dev-only
126
+ CLI HMAC token). A rejected request returns `401` with `code:
127
+ authorize_rejected` and a `hint` explaining how to authenticate.
128
+ - Inbound webhooks (`POST /webhook/:plug`, verified per-plug via `onVerify`),
129
+ the cron dispatcher (`CRON_SECRET`), and debug routes (`KHOTAN_DEBUG`,
130
+ non-production only) are exempt from `authorize` automatically.
131
+ - Also protect the Hub dashboard page (e.g. `/config`) with your app's
132
+ middleware — `authorize` only guards the API, not your React pages.
133
+ - Without `authorize`, khotan logs a startup warning. Always configure it
134
+ before deploying.
96
135
 
97
136
  ## Next.js Config
98
137
 
@@ -104,6 +143,76 @@ const nextConfig = {
104
143
  };
105
144
  ```
106
145
 
146
+ ## Workflow Runtime & Middleware/Proxy
147
+
148
+ Inflows, outflows, relays, catch, and pass run on **Vercel Workflow**, which
149
+ communicates over `/.well-known/workflow/*`. If your app has a `middleware.ts`
150
+ (or `proxy.ts`) whose `matcher` captures these paths, durable runs **silently
151
+ fail** — steps never get invoked and runs hang.
152
+
153
+ `npx khotan init` detects a middleware/proxy file and warns when it may
154
+ intercept these paths. Exclude them from the matcher:
155
+
156
+ ```typescript
157
+ // middleware.ts
158
+ export const config = {
159
+ matcher: ["/((?!_next|.well-known/workflow).*)"],
160
+ };
161
+ ```
162
+
163
+ If you do auth or rewrites manually (not via `matcher`), short-circuit early:
164
+
165
+ ```typescript
166
+ export function middleware(request: NextRequest) {
167
+ if (request.nextUrl.pathname.startsWith("/.well-known/workflow")) {
168
+ return NextResponse.next();
169
+ }
170
+ // ...your logic
171
+ }
172
+ ```
173
+
174
+ Vercel Workflow also requires AI Gateway OIDC — run `vercel link` and
175
+ `vercel env pull` so `VERCEL_OIDC_TOKEN` is available locally.
176
+
177
+ ## Triggering Flows
178
+
179
+ Start a flow through khotan (never call the workflow function directly) so run
180
+ tracking and Workflow IDs are recorded. The API is `khotanData.flow(name).start()`:
181
+
182
+ ```typescript
183
+ import khotanData from "@/lib/khotan/khotan";
184
+
185
+ await khotanData.flow("products-inflow", { plugName: "shopify" }).start({
186
+ runType: "delta", // or "full"
187
+ });
188
+ ```
189
+
190
+ `plugName` is only needed to disambiguate when the same flow name is registered
191
+ under multiple plugs. There is no `khotanData.api.*` or `flow().run()` surface —
192
+ `flow(name).start(options)` is the single entry point for manual and scheduled
193
+ runs alike. The cron dispatcher (`/api/khotan/cron`) calls this same path.
194
+
195
+ ### Triggering over HTTP (scripts / external services)
196
+
197
+ There is **no** `POST /flows/:name/run` route. The HTTP trigger is:
198
+
199
+ ```
200
+ POST /api/khotan/flows/{flowId}/runs body: { "runType": "delta" }
201
+ ```
202
+
203
+ This is a **management route**, so it goes through your `authorize` hook. Common
204
+ gotcha: `KHOTAN_SECRET` is an encryption key, **not** an HTTP credential — sending
205
+ `Authorization: Bearer <KHOTAN_SECRET>` returns `401` with `code: authorize_rejected`.
206
+ To trigger from outside the app, authenticate with a credential your `authorize`
207
+ hook accepts (a session cookie, or your own token you validate inside `authorize`).
208
+
209
+ Prefer triggering server-side with `khotanData.flow(name).start()` whenever you
210
+ can — it needs no HTTP round-trip or auth.
211
+
212
+ The `npx khotan flows trigger <name>` CLI works in **dev** without any of this: it
213
+ signs a short-lived HMAC token from `KHOTAN_SECRET` (the `KhotanCLI` auth scheme,
214
+ disabled when `NODE_ENV=production`). The raw secret never leaves your machine.
215
+
107
216
  ## Verify Setup
108
217
 
109
218
  ```bash
@@ -159,3 +268,5 @@ This keeps sync logic grounded in real API payloads before you write pagination,
159
268
  - **"Cannot find module khotan-data"**: Add to `serverExternalPackages` in next.config.ts
160
269
  - **Migration fails**: Ensure `DATABASE_URL` is set and Postgres is reachable
161
270
  - **Init won't overwrite**: By design — delete the file manually if you need to re-scaffold
271
+ - **Flow/workflow runs hang or never start**: Check your `middleware.ts`/`proxy.ts` matcher excludes `/.well-known/workflow/*` (see "Workflow Runtime & Middleware/Proxy")
272
+ - **Step "is not a function" / fails to resolve at runtime**: Declare `"use step"` functions at module top level and pass `ctx` as an argument — never nest them inside the `"use workflow"` function (closures over workflow scope cannot be hoisted)
@@ -110,19 +110,28 @@ npx khotan add catch --yes
110
110
 
111
111
  Process webhook events durably via Vercel Workflow:
112
112
 
113
- ```typescript
114
- import { catchEvent } from "./webhooks/catch";
115
-
116
- const processInvoice = catchEvent(async (ctx) => {
117
- "use workflow";
113
+ Declare `"use step"` functions at module top level and pass `ctx` (serializable
114
+ data) as an argument. Nesting steps inside the `"use workflow"` function fails at
115
+ runtime — closures over workflow scope cannot be hoisted.
118
116
 
119
- async function persist() {
120
- "use step";
121
- // Write to database retried on failure
122
- await db.insert(invoices).values(ctx.event);
123
- }
124
-
125
- await persist();
117
+ ```typescript
118
+ import { catchEvent, type CatchContext } from "./webhooks/catch";
119
+ import { db } from "@/db";
120
+ import { invoices } from "@/db/schema";
121
+
122
+ // Step: top-level, full Node.js access, retried on failure.
123
+ async function persistInvoice(ctx: CatchContext) {
124
+ "use step";
125
+ await db.insert(invoices).values(ctx.event);
126
+ }
127
+
128
+ const processInvoice = catchEvent({
129
+ name: "stripe-invoices",
130
+ events: ["invoice.paid"],
131
+ workflow: async (ctx) => {
132
+ "use workflow";
133
+ await persistInvoice(ctx);
134
+ },
126
135
  });
127
136
  ```
128
137
 
@@ -140,22 +149,35 @@ npx khotan add pass --yes
140
149
 
141
150
  Forward webhook events to another service:
142
151
 
152
+ The context exposes `ctx.event`, `ctx.eventType`, and `ctx.destVars` (the
153
+ decrypted credentials for the destination plug). There is no `ctx.destPlug` —
154
+ construct the destination plug from `destVars` inside a top-level step.
155
+
143
156
  ```typescript
144
- import { pass } from "./webhooks/pass";
157
+ import { pass, type PassContext } from "./webhooks/pass";
158
+ import { plug } from "@/lib/khotan/plugs/plug";
159
+
160
+ // Step: top-level. Build the destination plug from ctx.destVars.
161
+ async function forwardToSlackStep(ctx: PassContext) {
162
+ "use step";
163
+ const slack = plug({
164
+ name: "slack",
165
+ baseUrl: "https://slack.com/api",
166
+ authType: "bearer",
167
+ auth: { bearer: { token: ctx.destVars["botToken"] ?? "" } },
168
+ });
169
+ await slack.post("/chat.postMessage", {
170
+ body: { channel: ctx.destVars["channelId"], text: `New event: ${ctx.eventType}` },
171
+ });
172
+ }
145
173
 
146
174
  const forwardToSlack = pass({
147
- to: "slack", // Destination plug name (must be registered)
175
+ name: "stripe-to-slack",
176
+ to: "slack", // Destination plug name (must be registered)
177
+ events: ["invoice.paid"],
148
178
  workflow: async (ctx) => {
149
179
  "use workflow";
150
- // ctx.event — the incoming webhook payload
151
- // ctx.destVars — destination plug variables
152
- async function forward() {
153
- "use step";
154
- await ctx.destPlug.post("/messages", {
155
- body: { text: `New event: ${ctx.event.type}` },
156
- });
157
- }
158
- await forward();
180
+ await forwardToSlackStep(ctx);
159
181
  },
160
182
  });
161
183
  ```
@@ -18,6 +18,7 @@ import {
18
18
  type NodeProps,
19
19
  } from "@xyflow/react";
20
20
  import "@xyflow/react/dist/style.css";
21
+ import { khotanFetch, ApiErrorState } from "./api-state";
21
22
  import { Badge } from "@/components/ui/badge";
22
23
  import {
23
24
  Card,
@@ -1056,8 +1057,9 @@ function TopologyCanvasInner() {
1056
1057
  const [nodes, setNodes, onNodesChange] = useNodesState<TopologyNodeData>([]);
1057
1058
  const [edges, setEdges, onEdgesChange] = useEdgesState([]);
1058
1059
  const [loading, setLoading] = useState(true);
1059
- const [error, setError] = useState<string | null>(null);
1060
+ const [error, setError] = useState<unknown>(null);
1060
1061
  const [lastUpdatedAt, setLastUpdatedAt] = useState<string | null>(null);
1062
+ const [refreshKey, setRefreshKey] = useState(0);
1061
1063
 
1062
1064
  useEffect(() => {
1063
1065
  let cancelled = false;
@@ -1068,30 +1070,22 @@ function TopologyCanvasInner() {
1068
1070
  setError(null);
1069
1071
  }
1070
1072
 
1071
- const [plugsRes, flowsRes, runsRes] = await Promise.all([
1072
- fetch("/api/khotan/plugs"),
1073
- fetch("/api/khotan/flows"),
1074
- fetch("/api/khotan/runs?limit=100"),
1073
+ const [plugs, flows, runsRaw] = await Promise.all([
1074
+ khotanFetch<PlugRecord[]>("/api/khotan/plugs"),
1075
+ khotanFetch<FlowRecord[]>("/api/khotan/flows"),
1076
+ khotanFetch<unknown>("/api/khotan/runs?limit=100"),
1075
1077
  ]);
1076
1078
 
1077
- if (!plugsRes.ok || !flowsRes.ok || !runsRes.ok) {
1078
- throw new Error("Failed to load topology data from /api/khotan");
1079
- }
1080
-
1081
- const plugs = (await plugsRes.json()) as PlugRecord[];
1082
- const flows = (await flowsRes.json()) as FlowRecord[];
1083
- const runs = normalizeRuns(await runsRes.json());
1079
+ const runs = normalizeRuns(runsRaw);
1084
1080
 
1085
1081
  const webhookGroups = await Promise.all(
1086
1082
  plugs.map(async (plug) => {
1087
1083
  try {
1088
- const res = await fetch(
1084
+ const handlers = await khotanFetch<
1085
+ Array<Omit<WebhookHandlerRecord, "plugName">>
1086
+ >(
1089
1087
  `/api/khotan/webhook-handlers/${encodeURIComponent(plug.name)}`,
1090
1088
  );
1091
- if (!res.ok) return [] as WebhookHandlerRecord[];
1092
- const handlers = (await res.json()) as Array<
1093
- Omit<WebhookHandlerRecord, "plugName">
1094
- >;
1095
1089
  return handlers.map((handler) => ({
1096
1090
  ...handler,
1097
1091
  plugName: plug.name,
@@ -1113,11 +1107,7 @@ function TopologyCanvasInner() {
1113
1107
  setLastUpdatedAt(new Date().toISOString());
1114
1108
  } catch (loadError) {
1115
1109
  if (!cancelled) {
1116
- setError(
1117
- loadError instanceof Error
1118
- ? loadError.message
1119
- : "Unknown topology load failure",
1120
- );
1110
+ setError(loadError);
1121
1111
  }
1122
1112
  } finally {
1123
1113
  if (!cancelled) {
@@ -1135,7 +1125,7 @@ function TopologyCanvasInner() {
1135
1125
  cancelled = true;
1136
1126
  window.clearInterval(interval);
1137
1127
  };
1138
- }, []);
1128
+ }, [refreshKey]);
1139
1129
 
1140
1130
  const model = useMemo(() => {
1141
1131
  return snapshot ? buildTopologyModel(snapshot) : null;
@@ -1180,19 +1170,18 @@ function TopologyCanvasInner() {
1180
1170
 
1181
1171
  if (error) {
1182
1172
  return (
1183
- <Card className="border-red-200 bg-white/80 shadow-xl backdrop-blur">
1173
+ <Card className="border-white/70 bg-white/80 shadow-xl backdrop-blur">
1184
1174
  <CardHeader>
1185
1175
  <CardTitle>Topology Canvas</CardTitle>
1186
1176
  <CardDescription>
1187
1177
  Could not load the graph from the local Khotan API.
1188
1178
  </CardDescription>
1189
1179
  </CardHeader>
1190
- <CardContent className="space-y-3 text-sm">
1191
- <p className="text-red-600">{error}</p>
1192
- <p className="text-slate-500">
1193
- Make sure the catch-all Khotan route is mounted and your dev server
1194
- is running.
1195
- </p>
1180
+ <CardContent>
1181
+ <ApiErrorState
1182
+ error={error}
1183
+ onRetry={() => setRefreshKey((v) => v + 1)}
1184
+ />
1196
1185
  </CardContent>
1197
1186
  </Card>
1198
1187
  );