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.
- package/README.md +60 -19
- package/dist/cli.js +183 -46
- package/dist/factory.cjs +86 -9
- package/dist/factory.cjs.map +1 -1
- package/dist/factory.d.cts +46 -1
- package/dist/factory.d.ts +46 -1
- package/dist/factory.js +86 -10
- package/dist/factory.js.map +1 -1
- package/dist/templates/api-state.tsx +249 -0
- package/dist/templates/catch.example.ts +25 -17
- package/dist/templates/catch.ts +20 -15
- package/dist/templates/debug-index-page.tsx +56 -36
- package/dist/templates/hub.tsx +105 -36
- package/dist/templates/inflow.example.ts +46 -38
- package/dist/templates/inflow.ts +37 -31
- package/dist/templates/khotan-config.ts +28 -0
- package/dist/templates/mapping-browser.tsx +56 -44
- package/dist/templates/outflow.example.ts +39 -31
- package/dist/templates/outflow.ts +28 -23
- package/dist/templates/pass.example.ts +38 -30
- package/dist/templates/pass.ts +29 -24
- package/dist/templates/plug-debugger.tsx +15 -7
- package/dist/templates/relay.example.ts +52 -44
- package/dist/templates/relay.ts +38 -33
- package/dist/templates/runs-table.tsx +133 -130
- package/dist/templates/skill-dashboard.md +2 -1
- package/dist/templates/skill-setup.md +113 -2
- package/dist/templates/skill-webhook.md +45 -23
- package/dist/templates/topology-canvas.tsx +19 -30
- package/dist/templates/var-panel.tsx +33 -10
- package/dist/templates/webhook-events-table.tsx +105 -102
- package/dist/templates/wire-panel.tsx +30 -8
- package/package.json +1 -1
package/dist/templates/hub.tsx
CHANGED
|
@@ -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:
|
|
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
|
|
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<
|
|
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 [
|
|
126
|
-
|
|
127
|
-
|
|
139
|
+
const [plugsData, flowsData] = await Promise.all([
|
|
140
|
+
khotanFetch<Plug[]>("/api/khotan/plugs"),
|
|
141
|
+
khotanFetch<Flow[]>("/api/khotan/flows"),
|
|
128
142
|
]);
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
setPlugs(await plugsRes.json());
|
|
133
|
-
setFlows(await flowsRes.json());
|
|
143
|
+
setPlugs(plugsData);
|
|
144
|
+
setFlows(flowsData);
|
|
134
145
|
} catch (err) {
|
|
135
|
-
setError(err
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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-
|
|
260
|
-
selectedPlugId === plug.id
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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({
|
package/dist/templates/inflow.ts
CHANGED
|
@@ -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
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
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
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
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
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
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
|
|
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(
|
|
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 (
|
|
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<
|
|
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
|
|
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(
|
|
161
|
+
setError(error);
|
|
160
162
|
} finally {
|
|
161
163
|
setResourcesLoading(false);
|
|
162
164
|
}
|
|
163
165
|
}
|
|
164
166
|
|
|
165
|
-
async function fetchMappings(
|
|
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
|
|
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(
|
|
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) => [
|
|
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
|
|
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">
|
|
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
|
|
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
|
-
<
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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]
|
|
541
|
-
|
|
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(
|
|
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
|
|
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">
|
|
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.
|
|
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
|