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.
@@ -15,6 +15,7 @@ import { Switch } from "@/components/ui/switch";
15
15
  import { Button } from "@/components/ui/button";
16
16
  import { WirePanel } from "./wire";
17
17
  import { VarPanel } from "./var-panel";
18
+ import { khotanFetch, ApiErrorState } from "./api-state";
18
19
 
19
20
  // ============================================================================
20
21
  // Khotan Hub — Dashboard for configured plugs and flows
@@ -45,7 +46,14 @@ interface Flow {
45
46
  enabled: boolean;
46
47
  schedule: string | null;
47
48
  lastRunAt: string | null;
48
- lastRunStatus: "completed" | "partial" | "failed" | "cancelled" | null;
49
+ lastRunStatus:
50
+ | "pending"
51
+ | "running"
52
+ | "completed"
53
+ | "partial"
54
+ | "failed"
55
+ | "cancelled"
56
+ | null;
49
57
  plugName: string | null;
50
58
  }
51
59
 
@@ -98,19 +106,25 @@ const runStatusVariant: Record<string, StatusVariant> = {
98
106
  export function KhotanHub({
99
107
  webhookUrl,
100
108
  debugHref,
101
- logsHref = "/logs",
109
+ logsHref,
102
110
  }: {
103
111
  webhookUrl?: string;
104
112
  debugHref?: (plugName: string) => string;
113
+ /** When set, shows an "Open Logs" button linking here. Hidden if omitted. */
105
114
  logsHref?: string;
106
115
  } = {}) {
107
116
  const [plugs, setPlugs] = useState<Plug[]>([]);
108
117
  const [flows, setFlows] = useState<Flow[]>([]);
109
118
  const [webhookHandlers, setWebhookHandlers] = useState<WebhookHandler[]>([]);
110
119
  const [loading, setLoading] = useState(true);
111
- const [error, setError] = useState<string | null>(null);
120
+ const [error, setError] = useState<unknown>(null);
112
121
  const [selectedPlugId, setSelectedPlugId] = useState<string | null>(null);
113
122
  const [debugEnabled, setDebugEnabled] = useState(false);
123
+ const [runningFlowId, setRunningFlowId] = useState<string | null>(null);
124
+ const [runNotice, setRunNotice] = useState<{
125
+ type: "ok" | "error";
126
+ text: string;
127
+ } | null>(null);
114
128
 
115
129
  useEffect(() => {
116
130
  fetch("/api/khotan/debug")
@@ -122,17 +136,14 @@ export function KhotanHub({
122
136
  setLoading(true);
123
137
  setError(null);
124
138
  try {
125
- const [plugsRes, flowsRes] = await Promise.all([
126
- fetch("/api/khotan/plugs"),
127
- fetch("/api/khotan/flows"),
139
+ const [plugsData, flowsData] = await Promise.all([
140
+ khotanFetch<Plug[]>("/api/khotan/plugs"),
141
+ khotanFetch<Flow[]>("/api/khotan/flows"),
128
142
  ]);
129
- if (!plugsRes.ok || !flowsRes.ok) {
130
- throw new Error("Failed to fetch khotan data");
131
- }
132
- setPlugs(await plugsRes.json());
133
- setFlows(await flowsRes.json());
143
+ setPlugs(plugsData);
144
+ setFlows(flowsData);
134
145
  } catch (err) {
135
- setError(err instanceof Error ? err.message : "Unknown error");
146
+ setError(err);
136
147
  } finally {
137
148
  setLoading(false);
138
149
  }
@@ -149,6 +160,35 @@ export function KhotanHub({
149
160
  });
150
161
  }
151
162
 
163
+ async function runFlow(flowId: string, flowName: string) {
164
+ setRunningFlowId(flowId);
165
+ setRunNotice(null);
166
+ try {
167
+ const res = await fetch(`/api/khotan/flows/${flowId}/runs`, {
168
+ method: "POST",
169
+ headers: { "Content-Type": "application/json" },
170
+ body: JSON.stringify({ runType: "full" }),
171
+ });
172
+ if (!res.ok) {
173
+ const data = (await res.json().catch(() => ({}))) as {
174
+ error?: string;
175
+ hint?: string;
176
+ };
177
+ setRunNotice({
178
+ type: "error",
179
+ text: data.hint ?? data.error ?? `Failed to trigger ${flowName}.`,
180
+ });
181
+ return;
182
+ }
183
+ setRunNotice({ type: "ok", text: `Triggered ${flowName}.` });
184
+ await fetchData();
185
+ } catch {
186
+ setRunNotice({ type: "error", text: `Failed to trigger ${flowName}.` });
187
+ } finally {
188
+ setRunningFlowId(null);
189
+ }
190
+ }
191
+
152
192
  async function toggleWebhookHandler(handlerId: string, enabled: boolean) {
153
193
  setWebhookHandlers((prev) =>
154
194
  prev.map((h) => (h.id === handlerId ? { ...h, enabled } : h)),
@@ -201,19 +241,7 @@ export function KhotanHub({
201
241
  }
202
242
 
203
243
  if (error) {
204
- return (
205
- <Card>
206
- <CardContent className="py-8 text-center">
207
- <p className="text-destructive mb-4">{error}</p>
208
- <button
209
- onClick={fetchData}
210
- className="text-sm underline hover:no-underline"
211
- >
212
- Retry
213
- </button>
214
- </CardContent>
215
- </Card>
216
- );
244
+ return <ApiErrorState error={error} onRetry={fetchData} />;
217
245
  }
218
246
 
219
247
  if (plugs.length === 0) {
@@ -241,23 +269,27 @@ export function KhotanHub({
241
269
  <div className="space-y-6">
242
270
  <div className="flex items-center justify-between gap-4">
243
271
  <h2 className="text-2xl font-bold tracking-tight">Khotan Hub</h2>
244
- <Button
245
- variant="outline"
246
- size="sm"
247
- onClick={() => {
248
- window.location.href = logsHref;
249
- }}
250
- >
251
- Open Logs
252
- </Button>
272
+ {logsHref && (
273
+ <Button
274
+ variant="outline"
275
+ size="sm"
276
+ onClick={() => {
277
+ window.location.href = logsHref;
278
+ }}
279
+ >
280
+ Open Logs
281
+ </Button>
282
+ )}
253
283
  </div>
254
284
 
255
285
  <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
256
286
  {plugs.map((plug) => (
257
287
  <Card
258
288
  key={plug.id}
259
- className={`cursor-pointer transition-colors hover:border-primary ${
260
- selectedPlugId === plug.id ? "border-primary" : ""
289
+ className={`cursor-pointer transition-all hover:border-primary ${
290
+ selectedPlugId === plug.id
291
+ ? "border-primary ring-2 ring-primary ring-offset-2 ring-offset-background shadow-md"
292
+ : "border-border"
261
293
  }`}
262
294
  onClick={() =>
263
295
  setSelectedPlugId(selectedPlugId === plug.id ? null : plug.id)
@@ -301,6 +333,18 @@ export function KhotanHub({
301
333
  ))}
302
334
  </div>
303
335
 
336
+ {!selectedPlug && (
337
+ <Card className="border-dashed">
338
+ <CardContent className="py-10 text-center">
339
+ <p className="text-muted-foreground mb-1">No plug selected yet.</p>
340
+ <p className="text-sm text-muted-foreground">
341
+ Select a plug above to view its flows, webhook handlers, and
342
+ credentials.
343
+ </p>
344
+ </CardContent>
345
+ </Card>
346
+ )}
347
+
304
348
  {selectedPlug && (
305
349
  <div className="space-y-4">
306
350
  <Card>
@@ -308,6 +352,17 @@ export function KhotanHub({
308
352
  <CardTitle>{selectedPlug.name} — Flows</CardTitle>
309
353
  </CardHeader>
310
354
  <CardContent>
355
+ {runNotice && (
356
+ <p
357
+ className={`mb-3 text-sm ${
358
+ runNotice.type === "error"
359
+ ? "text-destructive"
360
+ : "text-muted-foreground"
361
+ }`}
362
+ >
363
+ {runNotice.text}
364
+ </p>
365
+ )}
311
366
  {plugFlows.length === 0 ? (
312
367
  <p className="text-sm text-muted-foreground">
313
368
  No flows configured for this plug.
@@ -321,6 +376,7 @@ export function KhotanHub({
321
376
  <TableHead>Schedule</TableHead>
322
377
  <TableHead>Last Run</TableHead>
323
378
  <TableHead>Enabled</TableHead>
379
+ <TableHead className="text-right">Actions</TableHead>
324
380
  </TableRow>
325
381
  </TableHeader>
326
382
  <TableBody>
@@ -359,6 +415,19 @@ export function KhotanHub({
359
415
  onCheckedChange={(v) => toggleFlow(flow.id, v)}
360
416
  />
361
417
  </TableCell>
418
+ <TableCell className="text-right">
419
+ <Button
420
+ size="sm"
421
+ variant="outline"
422
+ className="h-7 px-2 text-xs"
423
+ disabled={runningFlowId !== null}
424
+ onClick={() => runFlow(flow.id, flow.name)}
425
+ >
426
+ {runningFlowId === flow.id
427
+ ? "Running…"
428
+ : "Run now"}
429
+ </Button>
430
+ </TableCell>
362
431
  </TableRow>
363
432
  ))}
364
433
  </TableBody>
@@ -4,53 +4,61 @@
4
4
  //
5
5
  // Copy this file, rename it for your source service/resource, and register the
6
6
  // exported flow in {outputDir}/khotan.ts.
7
+ //
8
+ // IMPORTANT — Workflow step structure:
9
+ // Declare "use step" functions at module top level and pass them only
10
+ // serializable values (the `ctx` object is plain data and is safe to pass).
11
+ // Do NOT nest step functions inside the "use workflow" function — the Workflow
12
+ // compiler cannot hoist closures that capture workflow scope, and they fail at
13
+ // runtime in the sandbox. Keep the workflow body limited to orchestration.
7
14
  // ============================================================================
8
15
 
9
16
  import { inflow, type InflowContext } from "./inflow";
10
17
  import { sendUpdate } from "khotan-data/factory";
11
18
 
12
- async function shopifyProductsWorkflow(ctx: InflowContext) {
13
- "use workflow";
14
-
15
- async function extractAndLoad() {
16
- "use step";
17
- console.log("Starting inflow", {
18
- flow: ctx.flow.name,
19
- khotanRunId: ctx.khotanRunId,
20
- runType: ctx.runType,
21
- });
22
- await sendUpdate({
23
- message: "Starting product inflow",
24
- metadata: { flow: ctx.flow.name, runType: ctx.runType },
25
- });
19
+ // Step: full Node.js access, retried independently. Receives serializable ctx.
20
+ async function extractAndLoad(ctx: InflowContext) {
21
+ "use step";
22
+ console.log("Starting inflow", {
23
+ flow: ctx.flow.name,
24
+ khotanRunId: ctx.khotanRunId,
25
+ runType: ctx.runType,
26
+ });
27
+ await sendUpdate({
28
+ message: "Starting product inflow",
29
+ metadata: { flow: ctx.flow.name, runType: ctx.runType },
30
+ });
26
31
 
27
- const response = await fetch("https://api.example.com/products", {
28
- headers: {
29
- Authorization: `Bearer ${ctx.vars["apiToken"] ?? ""}`,
30
- },
31
- });
32
- const payload = (await response.json()) as {
33
- data?: Array<Record<string, unknown>>;
34
- };
35
- const records = Array.isArray(payload.data) ? payload.data : [];
32
+ const response = await fetch("https://api.example.com/products", {
33
+ headers: {
34
+ Authorization: `Bearer ${ctx.vars["apiToken"] ?? ""}`,
35
+ },
36
+ });
37
+ const payload = (await response.json()) as {
38
+ data?: Array<Record<string, unknown>>;
39
+ };
40
+ const records = Array.isArray(payload.data) ? payload.data : [];
36
41
 
37
- // Replace this with your app-specific transform and DB upsert.
38
- console.log("Fetched records", records.length);
39
- await sendUpdate({
40
- message: `Fetched ${String(records.length)} products`,
41
- extracted: records.length,
42
- progress: 50,
43
- });
42
+ // Replace this with your app-specific transform and DB upsert.
43
+ console.log("Fetched records", records.length);
44
+ await sendUpdate({
45
+ message: `Fetched ${String(records.length)} products`,
46
+ extracted: records.length,
47
+ progress: 50,
48
+ });
44
49
 
45
- return {
46
- extracted: records.length,
47
- transformed: records.length,
48
- created: records.length,
49
- metadata: { source: ctx.flow.name },
50
- };
51
- }
50
+ return {
51
+ extracted: records.length,
52
+ transformed: records.length,
53
+ created: records.length,
54
+ metadata: { source: ctx.flow.name },
55
+ };
56
+ }
52
57
 
53
- return extractAndLoad();
58
+ // Workflow: orchestration only. Calls top-level steps with serializable args.
59
+ async function shopifyProductsWorkflow(ctx: InflowContext) {
60
+ "use workflow";
61
+ return extractAndLoad(ctx);
54
62
  }
55
63
 
56
64
  export const shopifyProductsInflow = inflow({
@@ -48,46 +48,52 @@ export function inflow(config: InflowConfig): FlowRegistration {
48
48
  // Usage Example (create a file like flows/shopify-products.ts)
49
49
  // ---------------------------------------------------------------------------
50
50
  //
51
+ // Declare "use step" functions at MODULE TOP LEVEL and pass them serializable
52
+ // values only. The `ctx` object is plain data and is safe to pass. Do NOT nest
53
+ // step functions inside the "use workflow" function — the Workflow compiler
54
+ // cannot hoist closures that capture workflow scope, so they fail at runtime.
55
+ //
51
56
  // import { bindWorkflowPlug, inflow, type InflowContext, sendUpdate } from "khotan-data/factory";
52
57
  // import { db } from "@/db";
53
58
  // import { products } from "@/db/schema";
54
59
  // import { shopifyPlug } from "../plugs/shopify";
55
60
  //
56
- // async function shopifyProductsWorkflow(ctx: InflowContext) {
57
- // "use workflow";
58
- //
59
- // async function extractAndLoad() {
60
- // "use step";
61
- // console.log("Starting inflow", {
62
- // flow: ctx.flow.name,
63
- // khotanRunId: ctx.khotanRunId,
64
- // runType: ctx.runType,
65
- // });
66
- // await sendUpdate({ message: "Starting Shopify products inflow" });
67
- // const shopify = bindWorkflowPlug(shopifyPlug, ctx);
68
- //
69
- // const response = await shopify.get<{ data?: Array<{ id: string; sku?: string }> }>("/products");
70
- // const records = Array.isArray(response.data) ? response.data : [];
71
- // await sendUpdate({ message: `Fetched ${records.length} products`, extracted: records.length });
61
+ // // Step: top-level, full Node.js access, retried independently.
62
+ // async function extractAndLoad(ctx: InflowContext) {
63
+ // "use step";
64
+ // console.log("Starting inflow", {
65
+ // flow: ctx.flow.name,
66
+ // khotanRunId: ctx.khotanRunId,
67
+ // runType: ctx.runType,
68
+ // });
69
+ // await sendUpdate({ message: "Starting Shopify products inflow" });
70
+ // const shopify = bindWorkflowPlug(shopifyPlug, ctx);
72
71
  //
73
- // if (records.length > 0) {
74
- // await db.insert(products).values(
75
- // records.map((record) => ({
76
- // externalId: record.id,
77
- // sku: record.sku ?? record.id,
78
- // })),
79
- // );
80
- // }
72
+ // const response = await shopify.get<{ data?: Array<{ id: string; sku?: string }> }>("/products");
73
+ // const records = Array.isArray(response.data) ? response.data : [];
74
+ // await sendUpdate({ message: `Fetched ${records.length} products`, extracted: records.length });
81
75
  //
82
- // return {
83
- // extracted: records.length,
84
- // transformed: records.length,
85
- // created: records.length,
86
- // metadata: { source: ctx.flow.name },
87
- // };
76
+ // if (records.length > 0) {
77
+ // await db.insert(products).values(
78
+ // records.map((record) => ({
79
+ // externalId: record.id,
80
+ // sku: record.sku ?? record.id,
81
+ // })),
82
+ // );
88
83
  // }
89
84
  //
90
- // return extractAndLoad();
85
+ // return {
86
+ // extracted: records.length,
87
+ // transformed: records.length,
88
+ // created: records.length,
89
+ // metadata: { source: ctx.flow.name },
90
+ // };
91
+ // }
92
+ //
93
+ // // Workflow: orchestration only.
94
+ // async function shopifyProductsWorkflow(ctx: InflowContext) {
95
+ // "use workflow";
96
+ // return extractAndLoad(ctx);
91
97
  // }
92
98
  //
93
99
  // export const shopifyProductsInflow = inflow({
@@ -13,6 +13,23 @@ import { db } from "@/db";
13
13
  const khotanData = khotan({
14
14
  adapter: drizzleAdapter(db),
15
15
 
16
+ // ── Security ──────────────────────────────────────────────────────────────
17
+ // The management API (/api/khotan/*) and the Hub dashboard expose plug
18
+ // credentials and operational controls. Gate every management route behind
19
+ // your auth layer with `authorize`. It receives the raw Request, so it works
20
+ // directly with session libraries like better-auth:
21
+ //
22
+ // import { auth } from "@/lib/auth";
23
+ //
24
+ // authorize: async (request) => {
25
+ // const session = await auth.api.getSession({ headers: request.headers });
26
+ // return Boolean(session?.user); // or: session?.user?.role === "admin"
27
+ // },
28
+ //
29
+ // Inbound webhooks, the cron dispatcher (CRON_SECRET), and debug routes are
30
+ // exempt automatically. Without `authorize`, the API is PUBLIC.
31
+ // authorize: async (request) => { /* return true to allow */ return false; },
32
+
16
33
  // Resources define logical entity types for cross-referencing across plugs.
17
34
  // The mapping block declares the shared identity contract for that resource.
18
35
  resources: [
@@ -47,3 +64,14 @@ const khotanData = khotan({
47
64
  });
48
65
 
49
66
  export default khotanData;
67
+
68
+ // Trigger a flow run from server code (route handler, action, cron):
69
+ //
70
+ // import khotanData from "@/lib/khotan/khotan";
71
+ // await khotanData.flow("products-inflow", { plugName: "stripe" }).start({
72
+ // runType: "delta", // or "full"
73
+ // });
74
+ //
75
+ // `flow(name).start(options)` is the single entry point — there is no
76
+ // `khotanData.api.*` surface. `plugName` only disambiguates when the same flow
77
+ // name is registered under multiple plugs.
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect, useMemo, useState } from "react";
4
+ import { khotanFetch, ApiErrorState } from "./api-state";
4
5
  import { Button } from "@/components/ui/button";
5
6
  import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
6
7
  import { Input } from "@/components/ui/input";
@@ -60,7 +61,9 @@ function readErrorMessage(error: unknown): string {
60
61
  return "Unknown error";
61
62
  }
62
63
 
63
- function toPrettyJson(value: Record<string, unknown> | null | undefined): string {
64
+ function toPrettyJson(
65
+ value: Record<string, unknown> | null | undefined,
66
+ ): string {
64
67
  return value ? JSON.stringify(value, null, 2) : "";
65
68
  }
66
69
 
@@ -92,7 +95,10 @@ function parseConnectValueInput(
92
95
  }
93
96
 
94
97
  const parsed = JSON.parse(trimmed) as unknown;
95
- if (!Array.isArray(parsed) || parsed.some((value) => typeof value !== "string")) {
98
+ if (
99
+ !Array.isArray(parsed) ||
100
+ parsed.some((value) => typeof value !== "string")
101
+ ) {
96
102
  throw new Error(
97
103
  "Composite connect values must be provided as a JSON string array in declared field order.",
98
104
  );
@@ -119,7 +125,7 @@ export function KhotanMappingBrowser({
119
125
  const [mappingsLoading, setMappingsLoading] = useState(false);
120
126
  const [search, setSearch] = useState("");
121
127
  const [offset, setOffset] = useState(0);
122
- const [error, setError] = useState<string | null>(null);
128
+ const [error, setError] = useState<unknown>(null);
123
129
  const [actionError, setActionError] = useState<string | null>(null);
124
130
  const [submitting, setSubmitting] = useState(false);
125
131
  const [formMode, setFormMode] = useState<FormMode | null>(null);
@@ -142,11 +148,7 @@ export function KhotanMappingBrowser({
142
148
  setResourcesLoading(true);
143
149
  setError(null);
144
150
  try {
145
- const res = await fetch("/api/khotan/resources");
146
- if (!res.ok) {
147
- throw new Error("Failed to fetch resources from /api/khotan/resources");
148
- }
149
- const data = (await res.json()) as ResourceRecord[];
151
+ const data = await khotanFetch<ResourceRecord[]>("/api/khotan/resources");
150
152
  setResources(data);
151
153
 
152
154
  setSelectedResourceId((current) => {
@@ -156,13 +158,17 @@ export function KhotanMappingBrowser({
156
158
  return current || data[0]!.id;
157
159
  });
158
160
  } catch (error) {
159
- setError(readErrorMessage(error));
161
+ setError(error);
160
162
  } finally {
161
163
  setResourcesLoading(false);
162
164
  }
163
165
  }
164
166
 
165
- async function fetchMappings(resourceId: string, nextOffset: number, term: string) {
167
+ async function fetchMappings(
168
+ resourceId: string,
169
+ nextOffset: number,
170
+ term: string,
171
+ ) {
166
172
  setMappingsLoading(true);
167
173
  setError(null);
168
174
  try {
@@ -175,15 +181,11 @@ export function KhotanMappingBrowser({
175
181
  if (term.trim()) {
176
182
  url.searchParams.set("search", term.trim());
177
183
  }
178
- const res = await fetch(url.toString());
179
- if (!res.ok) {
180
- throw new Error("Failed to fetch mappings for the selected resource");
181
- }
182
- const data = (await res.json()) as MappingPage;
184
+ const data = await khotanFetch<MappingPage>(url.toString());
183
185
  setMappings(data.items);
184
186
  setPage(data.page);
185
187
  } catch (error) {
186
- setError(readErrorMessage(error));
188
+ setError(error);
187
189
  setMappings([]);
188
190
  setPage(null);
189
191
  } finally {
@@ -238,7 +240,10 @@ export function KhotanMappingBrowser({
238
240
 
239
241
  if (declaredPlugNames.length > 0) {
240
242
  const nextDeclaredRefs = Object.fromEntries(
241
- declaredPlugNames.map((plugName) => [plugName, mapping.refs[plugName] ?? ""]),
243
+ declaredPlugNames.map((plugName) => [
244
+ plugName,
245
+ mapping.refs[plugName] ?? "",
246
+ ]),
242
247
  );
243
248
  setDeclaredRefs(nextDeclaredRefs);
244
249
  setDynamicRefs([]);
@@ -355,7 +360,9 @@ export function KhotanMappingBrowser({
355
360
  }
356
361
 
357
362
  const nextOffset =
358
- mappings.length === 1 && offset > 0 ? Math.max(offset - pageSize, 0) : offset;
363
+ mappings.length === 1 && offset > 0
364
+ ? Math.max(offset - pageSize, 0)
365
+ : offset;
359
366
  setOffset(nextOffset);
360
367
  await fetchMappings(mapping.resourceId, nextOffset, search);
361
368
  } catch (error) {
@@ -460,33 +467,30 @@ export function KhotanMappingBrowser({
460
467
  </div>
461
468
 
462
469
  {resourcesLoading ? (
463
- <div className="text-muted-foreground text-sm">Loading resources...</div>
470
+ <div className="text-muted-foreground text-sm">
471
+ Loading resources...
472
+ </div>
464
473
  ) : null}
465
474
 
466
475
  {!resourcesLoading && resources.length === 0 ? (
467
476
  <div className="text-muted-foreground text-sm">
468
- No resources are registered yet. Mappings require registered resources
469
- in your `khotan()` config.
477
+ No resources are registered yet. Mappings require registered
478
+ resources in your `khotan()` config.
470
479
  </div>
471
480
  ) : null}
472
481
 
473
482
  {error ? (
474
- <div className="space-y-2 rounded-md border border-destructive/30 bg-destructive/5 p-4">
475
- <p className="text-sm text-destructive">{error}</p>
476
- <Button
477
- variant="outline"
478
- size="sm"
479
- onClick={() => {
480
- if (selectedResourceId) {
481
- void fetchMappings(selectedResourceId, offset, search);
482
- } else {
483
- void fetchResources();
484
- }
485
- }}
486
- >
487
- Retry
488
- </Button>
489
- </div>
483
+ <ApiErrorState
484
+ error={error}
485
+ onRetry={() => {
486
+ if (selectedResourceId) {
487
+ void fetchMappings(selectedResourceId, offset, search);
488
+ } else {
489
+ void fetchResources();
490
+ }
491
+ }}
492
+ compact
493
+ />
490
494
  ) : null}
491
495
  </CardContent>
492
496
  </Card>
@@ -537,8 +541,8 @@ export function KhotanMappingBrowser({
537
541
  id={`ref-${plugName}`}
538
542
  value={declaredRefs[plugName] ?? ""}
539
543
  placeholder={
540
- selectedResource?.mapping.plugs?.[plugName]?.uniqueIdentifier ??
541
- "External ID"
544
+ selectedResource?.mapping.plugs?.[plugName]
545
+ ?.uniqueIdentifier ?? "External ID"
542
546
  }
543
547
  onChange={(event) =>
544
548
  setDeclaredRefs((current) => ({
@@ -590,7 +594,9 @@ export function KhotanMappingBrowser({
590
594
  setDynamicRefs((current) =>
591
595
  current.length === 1
592
596
  ? [{ plugName: "", ref: "" }]
593
- : current.filter((_, itemIndex) => itemIndex !== index),
597
+ : current.filter(
598
+ (_, itemIndex) => itemIndex !== index,
599
+ ),
594
600
  )
595
601
  }
596
602
  >
@@ -643,7 +649,11 @@ export function KhotanMappingBrowser({
643
649
  ? "Create Mapping"
644
650
  : "Save Changes"}
645
651
  </Button>
646
- <Button variant="outline" onClick={resetForm} disabled={submitting}>
652
+ <Button
653
+ variant="outline"
654
+ onClick={resetForm}
655
+ disabled={submitting}
656
+ >
647
657
  Cancel
648
658
  </Button>
649
659
  </div>
@@ -662,7 +672,9 @@ export function KhotanMappingBrowser({
662
672
  </CardHeader>
663
673
  <CardContent>
664
674
  {mappingsLoading ? (
665
- <div className="text-muted-foreground text-sm">Loading mappings...</div>
675
+ <div className="text-muted-foreground text-sm">
676
+ Loading mappings...
677
+ </div>
666
678
  ) : null}
667
679
 
668
680
  {!mappingsLoading &&
@@ -729,8 +741,8 @@ export function KhotanMappingBrowser({
729
741
  {page ? (
730
742
  <div className="flex flex-wrap items-center justify-between gap-3">
731
743
  <p className="text-muted-foreground text-sm">
732
- Showing {page.offset + 1}-
733
- {page.offset + mappings.length} of {page.total}
744
+ Showing {page.offset + 1}-{page.offset + mappings.length} of{" "}
745
+ {page.total}
734
746
  </p>
735
747
  <div className="flex gap-2">
736
748
  <Button