khotan-data 0.1.0 → 0.2.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 +89 -6
- package/dist/cli.js +405 -35
- package/dist/factory.cjs +1160 -106
- package/dist/factory.cjs.map +1 -1
- package/dist/factory.d.cts +262 -38
- package/dist/factory.d.ts +262 -38
- package/dist/factory.js +1158 -108
- package/dist/factory.js.map +1 -1
- package/dist/templates/api-state.tsx +249 -0
- package/dist/templates/cache.example.ts +11 -0
- package/dist/templates/cache.ts +58 -0
- package/dist/templates/catch.ts +13 -1
- package/dist/templates/debug-index-page.tsx +56 -36
- package/dist/templates/hub.tsx +9 -23
- package/dist/templates/inflow.ts +5 -6
- package/dist/templates/khotan-config.ts +30 -4
- package/dist/templates/mapping-browser.tsx +773 -0
- package/dist/templates/mappings-page.tsx +9 -0
- package/dist/templates/outflow.ts +5 -5
- package/dist/templates/pass.ts +10 -0
- package/dist/templates/plug-debugger.tsx +15 -7
- package/dist/templates/relay.example.ts +11 -1
- package/dist/templates/relay.ts +16 -7
- package/dist/templates/runs-table.tsx +133 -130
- package/dist/templates/schema.ts +81 -0
- package/dist/templates/skill-plug.md +38 -15
- package/dist/templates/skill-setup.md +80 -3
- 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
|
@@ -0,0 +1,773 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useState } from "react";
|
|
4
|
+
import { khotanFetch, ApiErrorState } from "./api-state";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
7
|
+
import { Input } from "@/components/ui/input";
|
|
8
|
+
import { Label } from "@/components/ui/label";
|
|
9
|
+
import {
|
|
10
|
+
Table,
|
|
11
|
+
TableBody,
|
|
12
|
+
TableCell,
|
|
13
|
+
TableHead,
|
|
14
|
+
TableHeader,
|
|
15
|
+
TableRow,
|
|
16
|
+
} from "@/components/ui/table";
|
|
17
|
+
|
|
18
|
+
interface ResourceDefinition {
|
|
19
|
+
uniqueIdentifier: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ResourceRecord {
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
description?: string | null;
|
|
26
|
+
mapping: {
|
|
27
|
+
connectField: string | string[];
|
|
28
|
+
plugs?: Record<string, ResourceDefinition>;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface MappingRecord {
|
|
33
|
+
id: string;
|
|
34
|
+
resourceId: string;
|
|
35
|
+
connectValue: string;
|
|
36
|
+
refs: Record<string, string>;
|
|
37
|
+
metadata?: Record<string, unknown> | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface MappingPage {
|
|
41
|
+
items: MappingRecord[];
|
|
42
|
+
page: {
|
|
43
|
+
limit: number;
|
|
44
|
+
offset: number;
|
|
45
|
+
hasMore: boolean;
|
|
46
|
+
prevOffset: number;
|
|
47
|
+
nextOffset: number;
|
|
48
|
+
total: number;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface RefEntry {
|
|
53
|
+
plugName: string;
|
|
54
|
+
ref: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type FormMode = "create" | "edit";
|
|
58
|
+
|
|
59
|
+
function readErrorMessage(error: unknown): string {
|
|
60
|
+
if (error instanceof Error) return error.message;
|
|
61
|
+
return "Unknown error";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function toPrettyJson(
|
|
65
|
+
value: Record<string, unknown> | null | undefined,
|
|
66
|
+
): string {
|
|
67
|
+
return value ? JSON.stringify(value, null, 2) : "";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseMetadata(text: string): Record<string, unknown> | null {
|
|
71
|
+
const trimmed = text.trim();
|
|
72
|
+
if (!trimmed) return null;
|
|
73
|
+
const parsed = JSON.parse(trimmed) as unknown;
|
|
74
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
75
|
+
throw new Error("Metadata must be a JSON object.");
|
|
76
|
+
}
|
|
77
|
+
return parsed as Record<string, unknown>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function toRefEntries(refs: Record<string, string>): RefEntry[] {
|
|
81
|
+
return Object.entries(refs).map(([plugName, ref]) => ({ plugName, ref }));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function parseConnectValueInput(
|
|
85
|
+
resource: ResourceRecord | null,
|
|
86
|
+
rawValue: string,
|
|
87
|
+
): string | string[] {
|
|
88
|
+
if (!resource || !Array.isArray(resource.mapping.connectField)) {
|
|
89
|
+
return rawValue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const trimmed = rawValue.trim();
|
|
93
|
+
if (!trimmed.startsWith("[")) {
|
|
94
|
+
return rawValue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const parsed = JSON.parse(trimmed) as unknown;
|
|
98
|
+
if (
|
|
99
|
+
!Array.isArray(parsed) ||
|
|
100
|
+
parsed.some((value) => typeof value !== "string")
|
|
101
|
+
) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
"Composite connect values must be provided as a JSON string array in declared field order.",
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return parsed;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function formatConnectField(connectField: string | string[]): string {
|
|
110
|
+
return Array.isArray(connectField)
|
|
111
|
+
? connectField.join(" -> ")
|
|
112
|
+
: String(connectField);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function KhotanMappingBrowser({
|
|
116
|
+
pageSize = 20,
|
|
117
|
+
}: {
|
|
118
|
+
pageSize?: number;
|
|
119
|
+
} = {}) {
|
|
120
|
+
const [resources, setResources] = useState<ResourceRecord[]>([]);
|
|
121
|
+
const [resourcesLoading, setResourcesLoading] = useState(true);
|
|
122
|
+
const [selectedResourceId, setSelectedResourceId] = useState<string>("");
|
|
123
|
+
const [mappings, setMappings] = useState<MappingRecord[]>([]);
|
|
124
|
+
const [page, setPage] = useState<MappingPage["page"] | null>(null);
|
|
125
|
+
const [mappingsLoading, setMappingsLoading] = useState(false);
|
|
126
|
+
const [search, setSearch] = useState("");
|
|
127
|
+
const [offset, setOffset] = useState(0);
|
|
128
|
+
const [error, setError] = useState<unknown>(null);
|
|
129
|
+
const [actionError, setActionError] = useState<string | null>(null);
|
|
130
|
+
const [submitting, setSubmitting] = useState(false);
|
|
131
|
+
const [formMode, setFormMode] = useState<FormMode | null>(null);
|
|
132
|
+
const [editingMappingId, setEditingMappingId] = useState<string | null>(null);
|
|
133
|
+
const [connectValueInput, setConnectValueInput] = useState("");
|
|
134
|
+
const [metadataInput, setMetadataInput] = useState("");
|
|
135
|
+
const [dynamicRefs, setDynamicRefs] = useState<RefEntry[]>([
|
|
136
|
+
{ plugName: "", ref: "" },
|
|
137
|
+
]);
|
|
138
|
+
const [declaredRefs, setDeclaredRefs] = useState<Record<string, string>>({});
|
|
139
|
+
|
|
140
|
+
const selectedResource =
|
|
141
|
+
resources.find((resource) => resource.id === selectedResourceId) ?? null;
|
|
142
|
+
const declaredPlugNames = useMemo(
|
|
143
|
+
() => Object.keys(selectedResource?.mapping.plugs ?? {}),
|
|
144
|
+
[selectedResource],
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
async function fetchResources() {
|
|
148
|
+
setResourcesLoading(true);
|
|
149
|
+
setError(null);
|
|
150
|
+
try {
|
|
151
|
+
const data = await khotanFetch<ResourceRecord[]>("/api/khotan/resources");
|
|
152
|
+
setResources(data);
|
|
153
|
+
|
|
154
|
+
setSelectedResourceId((current) => {
|
|
155
|
+
if (data.length === 0) return "";
|
|
156
|
+
if (data.some((resource) => resource.id === current)) return current;
|
|
157
|
+
if (data.length === 1) return data[0]!.id;
|
|
158
|
+
return current || data[0]!.id;
|
|
159
|
+
});
|
|
160
|
+
} catch (error) {
|
|
161
|
+
setError(error);
|
|
162
|
+
} finally {
|
|
163
|
+
setResourcesLoading(false);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function fetchMappings(
|
|
168
|
+
resourceId: string,
|
|
169
|
+
nextOffset: number,
|
|
170
|
+
term: string,
|
|
171
|
+
) {
|
|
172
|
+
setMappingsLoading(true);
|
|
173
|
+
setError(null);
|
|
174
|
+
try {
|
|
175
|
+
const url = new URL(
|
|
176
|
+
`/api/khotan/resources/${resourceId}/mappings`,
|
|
177
|
+
window.location.origin,
|
|
178
|
+
);
|
|
179
|
+
url.searchParams.set("limit", String(pageSize));
|
|
180
|
+
url.searchParams.set("offset", String(nextOffset));
|
|
181
|
+
if (term.trim()) {
|
|
182
|
+
url.searchParams.set("search", term.trim());
|
|
183
|
+
}
|
|
184
|
+
const data = await khotanFetch<MappingPage>(url.toString());
|
|
185
|
+
setMappings(data.items);
|
|
186
|
+
setPage(data.page);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
setError(error);
|
|
189
|
+
setMappings([]);
|
|
190
|
+
setPage(null);
|
|
191
|
+
} finally {
|
|
192
|
+
setMappingsLoading(false);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
void fetchResources();
|
|
198
|
+
}, []);
|
|
199
|
+
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
if (!selectedResourceId) {
|
|
202
|
+
setMappings([]);
|
|
203
|
+
setPage(null);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
void fetchMappings(selectedResourceId, offset, search);
|
|
207
|
+
}, [selectedResourceId, offset, pageSize, search]);
|
|
208
|
+
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
setOffset(0);
|
|
211
|
+
}, [selectedResourceId]);
|
|
212
|
+
|
|
213
|
+
function resetForm() {
|
|
214
|
+
setFormMode(null);
|
|
215
|
+
setEditingMappingId(null);
|
|
216
|
+
setConnectValueInput("");
|
|
217
|
+
setMetadataInput("");
|
|
218
|
+
setDynamicRefs([{ plugName: "", ref: "" }]);
|
|
219
|
+
setDeclaredRefs({});
|
|
220
|
+
setActionError(null);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function openCreateForm() {
|
|
224
|
+
resetForm();
|
|
225
|
+
setFormMode("create");
|
|
226
|
+
if (declaredPlugNames.length > 0) {
|
|
227
|
+
setDeclaredRefs(
|
|
228
|
+
Object.fromEntries(declaredPlugNames.map((plugName) => [plugName, ""])),
|
|
229
|
+
);
|
|
230
|
+
setDynamicRefs([]);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function openEditForm(mapping: MappingRecord) {
|
|
235
|
+
setFormMode("edit");
|
|
236
|
+
setEditingMappingId(mapping.id);
|
|
237
|
+
setConnectValueInput(mapping.connectValue);
|
|
238
|
+
setMetadataInput(toPrettyJson(mapping.metadata));
|
|
239
|
+
setActionError(null);
|
|
240
|
+
|
|
241
|
+
if (declaredPlugNames.length > 0) {
|
|
242
|
+
const nextDeclaredRefs = Object.fromEntries(
|
|
243
|
+
declaredPlugNames.map((plugName) => [
|
|
244
|
+
plugName,
|
|
245
|
+
mapping.refs[plugName] ?? "",
|
|
246
|
+
]),
|
|
247
|
+
);
|
|
248
|
+
setDeclaredRefs(nextDeclaredRefs);
|
|
249
|
+
setDynamicRefs([]);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
setDeclaredRefs({});
|
|
254
|
+
setDynamicRefs(
|
|
255
|
+
toRefEntries(mapping.refs).length > 0
|
|
256
|
+
? toRefEntries(mapping.refs)
|
|
257
|
+
: [{ plugName: "", ref: "" }],
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function buildRefsPayload(): Record<string, string> {
|
|
262
|
+
if (declaredPlugNames.length > 0) {
|
|
263
|
+
return Object.fromEntries(
|
|
264
|
+
Object.entries(declaredRefs)
|
|
265
|
+
.map(([plugName, ref]) => [plugName, ref.trim()] as const)
|
|
266
|
+
.filter(([, ref]) => ref.length > 0),
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return Object.fromEntries(
|
|
271
|
+
dynamicRefs
|
|
272
|
+
.map((entry) => ({
|
|
273
|
+
plugName: entry.plugName.trim(),
|
|
274
|
+
ref: entry.ref.trim(),
|
|
275
|
+
}))
|
|
276
|
+
.filter((entry) => entry.plugName && entry.ref)
|
|
277
|
+
.map((entry) => [entry.plugName, entry.ref] as const),
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function submitForm() {
|
|
282
|
+
if (!selectedResource) {
|
|
283
|
+
setActionError("Select a resource before saving a mapping.");
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
setSubmitting(true);
|
|
288
|
+
setActionError(null);
|
|
289
|
+
|
|
290
|
+
try {
|
|
291
|
+
const metadata = parseMetadata(metadataInput);
|
|
292
|
+
const refs = buildRefsPayload();
|
|
293
|
+
const connectValue = parseConnectValueInput(
|
|
294
|
+
selectedResource,
|
|
295
|
+
connectValueInput,
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
if (
|
|
299
|
+
(typeof connectValue === "string" && !connectValue.trim()) ||
|
|
300
|
+
(Array.isArray(connectValue) && connectValue.length === 0)
|
|
301
|
+
) {
|
|
302
|
+
throw new Error("Connect value is required.");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (Object.keys(refs).length === 0) {
|
|
306
|
+
throw new Error("At least one ref is required.");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const body = {
|
|
310
|
+
resourceId: selectedResource.id,
|
|
311
|
+
connectValue,
|
|
312
|
+
refs,
|
|
313
|
+
metadata,
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const url =
|
|
317
|
+
formMode === "edit" && editingMappingId
|
|
318
|
+
? `/api/khotan/mappings/${editingMappingId}`
|
|
319
|
+
: "/api/khotan/mappings";
|
|
320
|
+
const method = formMode === "edit" ? "PUT" : "POST";
|
|
321
|
+
|
|
322
|
+
const res = await fetch(url, {
|
|
323
|
+
method,
|
|
324
|
+
headers: { "Content-Type": "application/json" },
|
|
325
|
+
body: JSON.stringify(body),
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
if (!res.ok) {
|
|
329
|
+
const payload = (await res.json().catch(() => ({}))) as {
|
|
330
|
+
error?: string;
|
|
331
|
+
};
|
|
332
|
+
throw new Error(payload.error ?? "Failed to save mapping.");
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
resetForm();
|
|
336
|
+
await fetchMappings(selectedResource.id, offset, search);
|
|
337
|
+
} catch (error) {
|
|
338
|
+
setActionError(readErrorMessage(error));
|
|
339
|
+
} finally {
|
|
340
|
+
setSubmitting(false);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function handleDelete(mapping: MappingRecord) {
|
|
345
|
+
const confirmed = window.confirm(
|
|
346
|
+
`Delete mapping "${mapping.connectValue}"? This removes the shared identity row for the selected resource.`,
|
|
347
|
+
);
|
|
348
|
+
if (!confirmed) return;
|
|
349
|
+
|
|
350
|
+
setActionError(null);
|
|
351
|
+
try {
|
|
352
|
+
const res = await fetch(`/api/khotan/mappings/${mapping.id}`, {
|
|
353
|
+
method: "DELETE",
|
|
354
|
+
});
|
|
355
|
+
if (!res.ok) {
|
|
356
|
+
const payload = (await res.json().catch(() => ({}))) as {
|
|
357
|
+
error?: string;
|
|
358
|
+
};
|
|
359
|
+
throw new Error(payload.error ?? "Failed to delete mapping.");
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const nextOffset =
|
|
363
|
+
mappings.length === 1 && offset > 0
|
|
364
|
+
? Math.max(offset - pageSize, 0)
|
|
365
|
+
: offset;
|
|
366
|
+
setOffset(nextOffset);
|
|
367
|
+
await fetchMappings(mapping.resourceId, nextOffset, search);
|
|
368
|
+
} catch (error) {
|
|
369
|
+
setActionError(readErrorMessage(error));
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function renderRefsSummary(mapping: MappingRecord) {
|
|
374
|
+
const entries = Object.entries(mapping.refs);
|
|
375
|
+
if (entries.length === 0) {
|
|
376
|
+
return <span className="text-muted-foreground text-sm">No refs</span>;
|
|
377
|
+
}
|
|
378
|
+
return (
|
|
379
|
+
<div className="space-y-1">
|
|
380
|
+
{entries.map(([plugName, ref]) => (
|
|
381
|
+
<div key={plugName} className="text-sm">
|
|
382
|
+
<span className="font-medium">{plugName}:</span> {ref}
|
|
383
|
+
</div>
|
|
384
|
+
))}
|
|
385
|
+
</div>
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function renderMetadataSummary(mapping: MappingRecord) {
|
|
390
|
+
const entries = Object.entries(mapping.metadata ?? {});
|
|
391
|
+
if (entries.length === 0) {
|
|
392
|
+
return <span className="text-muted-foreground text-sm">No metadata</span>;
|
|
393
|
+
}
|
|
394
|
+
return (
|
|
395
|
+
<div className="space-y-1">
|
|
396
|
+
{entries.map(([key, value]) => (
|
|
397
|
+
<div key={key} className="text-sm">
|
|
398
|
+
<span className="font-medium">{key}:</span> {String(value)}
|
|
399
|
+
</div>
|
|
400
|
+
))}
|
|
401
|
+
</div>
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return (
|
|
406
|
+
<div className="space-y-6">
|
|
407
|
+
<Card>
|
|
408
|
+
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
|
409
|
+
<div className="space-y-2">
|
|
410
|
+
<CardTitle>Mappings Browser</CardTitle>
|
|
411
|
+
<p className="text-muted-foreground text-sm">
|
|
412
|
+
Browse shared identities by resource, search by connect value, and
|
|
413
|
+
maintain per-plug refs without mixing them into metadata.
|
|
414
|
+
</p>
|
|
415
|
+
</div>
|
|
416
|
+
<Button onClick={openCreateForm} disabled={!selectedResourceId}>
|
|
417
|
+
Create Mapping
|
|
418
|
+
</Button>
|
|
419
|
+
</CardHeader>
|
|
420
|
+
<CardContent className="space-y-4">
|
|
421
|
+
<div className="grid gap-4 md:grid-cols-[minmax(0,240px)_1fr]">
|
|
422
|
+
<div className="space-y-2">
|
|
423
|
+
<Label htmlFor="resource-select">Resource</Label>
|
|
424
|
+
<select
|
|
425
|
+
id="resource-select"
|
|
426
|
+
className="border-input bg-background h-10 w-full rounded-md border px-3 text-sm"
|
|
427
|
+
value={selectedResourceId}
|
|
428
|
+
onChange={(event) => {
|
|
429
|
+
setSelectedResourceId(event.target.value);
|
|
430
|
+
setSearch("");
|
|
431
|
+
setOffset(0);
|
|
432
|
+
resetForm();
|
|
433
|
+
}}
|
|
434
|
+
disabled={resourcesLoading || resources.length === 0}
|
|
435
|
+
>
|
|
436
|
+
{resources.length === 0 ? (
|
|
437
|
+
<option value="">No resources</option>
|
|
438
|
+
) : (
|
|
439
|
+
resources.map((resource) => (
|
|
440
|
+
<option key={resource.id} value={resource.id}>
|
|
441
|
+
{resource.name}
|
|
442
|
+
</option>
|
|
443
|
+
))
|
|
444
|
+
)}
|
|
445
|
+
</select>
|
|
446
|
+
{selectedResource ? (
|
|
447
|
+
<p className="text-muted-foreground text-xs">
|
|
448
|
+
mapping.connectField:{" "}
|
|
449
|
+
{formatConnectField(selectedResource.mapping.connectField)}
|
|
450
|
+
</p>
|
|
451
|
+
) : null}
|
|
452
|
+
</div>
|
|
453
|
+
|
|
454
|
+
<div className="space-y-2">
|
|
455
|
+
<Label htmlFor="mapping-search">Search</Label>
|
|
456
|
+
<Input
|
|
457
|
+
id="mapping-search"
|
|
458
|
+
placeholder="Search connect values, refs, or metadata"
|
|
459
|
+
value={search}
|
|
460
|
+
onChange={(event) => {
|
|
461
|
+
setSearch(event.target.value);
|
|
462
|
+
setOffset(0);
|
|
463
|
+
}}
|
|
464
|
+
disabled={!selectedResourceId}
|
|
465
|
+
/>
|
|
466
|
+
</div>
|
|
467
|
+
</div>
|
|
468
|
+
|
|
469
|
+
{resourcesLoading ? (
|
|
470
|
+
<div className="text-muted-foreground text-sm">
|
|
471
|
+
Loading resources...
|
|
472
|
+
</div>
|
|
473
|
+
) : null}
|
|
474
|
+
|
|
475
|
+
{!resourcesLoading && resources.length === 0 ? (
|
|
476
|
+
<div className="text-muted-foreground text-sm">
|
|
477
|
+
No resources are registered yet. Mappings require registered
|
|
478
|
+
resources in your `khotan()` config.
|
|
479
|
+
</div>
|
|
480
|
+
) : null}
|
|
481
|
+
|
|
482
|
+
{error ? (
|
|
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
|
+
/>
|
|
494
|
+
) : null}
|
|
495
|
+
</CardContent>
|
|
496
|
+
</Card>
|
|
497
|
+
|
|
498
|
+
{formMode ? (
|
|
499
|
+
<Card>
|
|
500
|
+
<CardHeader>
|
|
501
|
+
<CardTitle>
|
|
502
|
+
{formMode === "create" ? "Create Mapping" : "Edit Mapping"}
|
|
503
|
+
</CardTitle>
|
|
504
|
+
</CardHeader>
|
|
505
|
+
<CardContent className="space-y-4">
|
|
506
|
+
<div className="space-y-2">
|
|
507
|
+
<Label htmlFor="connect-value">Connect Value</Label>
|
|
508
|
+
<Input
|
|
509
|
+
id="connect-value"
|
|
510
|
+
value={connectValueInput}
|
|
511
|
+
onChange={(event) => setConnectValueInput(event.target.value)}
|
|
512
|
+
placeholder={
|
|
513
|
+
Array.isArray(selectedResource?.mapping.connectField)
|
|
514
|
+
? 'Use the canonical string or JSON array, e.g. ["tenant-a","alice@example.com"]'
|
|
515
|
+
: "alice@example.com"
|
|
516
|
+
}
|
|
517
|
+
/>
|
|
518
|
+
{Array.isArray(selectedResource?.mapping.connectField) ? (
|
|
519
|
+
<p className="text-muted-foreground text-xs">
|
|
520
|
+
Composite resources can accept a JSON array in declared field
|
|
521
|
+
order: {selectedResource.mapping.connectField.join(" -> ")}.
|
|
522
|
+
</p>
|
|
523
|
+
) : null}
|
|
524
|
+
</div>
|
|
525
|
+
|
|
526
|
+
<div className="space-y-3">
|
|
527
|
+
<div>
|
|
528
|
+
<Label>Refs</Label>
|
|
529
|
+
<p className="text-muted-foreground text-xs">
|
|
530
|
+
Refs are the external per-plug identifiers for this shared
|
|
531
|
+
entity.
|
|
532
|
+
</p>
|
|
533
|
+
</div>
|
|
534
|
+
|
|
535
|
+
{declaredPlugNames.length > 0 ? (
|
|
536
|
+
<div className="grid gap-3 md:grid-cols-2">
|
|
537
|
+
{declaredPlugNames.map((plugName) => (
|
|
538
|
+
<div key={plugName} className="space-y-2">
|
|
539
|
+
<Label htmlFor={`ref-${plugName}`}>{plugName}</Label>
|
|
540
|
+
<Input
|
|
541
|
+
id={`ref-${plugName}`}
|
|
542
|
+
value={declaredRefs[plugName] ?? ""}
|
|
543
|
+
placeholder={
|
|
544
|
+
selectedResource?.mapping.plugs?.[plugName]
|
|
545
|
+
?.uniqueIdentifier ?? "External ID"
|
|
546
|
+
}
|
|
547
|
+
onChange={(event) =>
|
|
548
|
+
setDeclaredRefs((current) => ({
|
|
549
|
+
...current,
|
|
550
|
+
[plugName]: event.target.value,
|
|
551
|
+
}))
|
|
552
|
+
}
|
|
553
|
+
/>
|
|
554
|
+
</div>
|
|
555
|
+
))}
|
|
556
|
+
</div>
|
|
557
|
+
) : (
|
|
558
|
+
<div className="space-y-3">
|
|
559
|
+
{dynamicRefs.map((entry, index) => (
|
|
560
|
+
<div
|
|
561
|
+
key={`${entry.plugName}-${index}`}
|
|
562
|
+
className="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]"
|
|
563
|
+
>
|
|
564
|
+
<Input
|
|
565
|
+
placeholder="Plug name"
|
|
566
|
+
value={entry.plugName}
|
|
567
|
+
onChange={(event) =>
|
|
568
|
+
setDynamicRefs((current) =>
|
|
569
|
+
current.map((item, itemIndex) =>
|
|
570
|
+
itemIndex === index
|
|
571
|
+
? { ...item, plugName: event.target.value }
|
|
572
|
+
: item,
|
|
573
|
+
),
|
|
574
|
+
)
|
|
575
|
+
}
|
|
576
|
+
/>
|
|
577
|
+
<Input
|
|
578
|
+
placeholder="External ref"
|
|
579
|
+
value={entry.ref}
|
|
580
|
+
onChange={(event) =>
|
|
581
|
+
setDynamicRefs((current) =>
|
|
582
|
+
current.map((item, itemIndex) =>
|
|
583
|
+
itemIndex === index
|
|
584
|
+
? { ...item, ref: event.target.value }
|
|
585
|
+
: item,
|
|
586
|
+
),
|
|
587
|
+
)
|
|
588
|
+
}
|
|
589
|
+
/>
|
|
590
|
+
<Button
|
|
591
|
+
type="button"
|
|
592
|
+
variant="outline"
|
|
593
|
+
onClick={() =>
|
|
594
|
+
setDynamicRefs((current) =>
|
|
595
|
+
current.length === 1
|
|
596
|
+
? [{ plugName: "", ref: "" }]
|
|
597
|
+
: current.filter(
|
|
598
|
+
(_, itemIndex) => itemIndex !== index,
|
|
599
|
+
),
|
|
600
|
+
)
|
|
601
|
+
}
|
|
602
|
+
>
|
|
603
|
+
Remove
|
|
604
|
+
</Button>
|
|
605
|
+
</div>
|
|
606
|
+
))}
|
|
607
|
+
<Button
|
|
608
|
+
type="button"
|
|
609
|
+
variant="outline"
|
|
610
|
+
onClick={() =>
|
|
611
|
+
setDynamicRefs((current) => [
|
|
612
|
+
...current,
|
|
613
|
+
{ plugName: "", ref: "" },
|
|
614
|
+
])
|
|
615
|
+
}
|
|
616
|
+
>
|
|
617
|
+
Add Ref
|
|
618
|
+
</Button>
|
|
619
|
+
</div>
|
|
620
|
+
)}
|
|
621
|
+
</div>
|
|
622
|
+
|
|
623
|
+
<div className="space-y-2">
|
|
624
|
+
<Label htmlFor="metadata-json">Metadata</Label>
|
|
625
|
+
<p className="text-muted-foreground text-xs">
|
|
626
|
+
Metadata is for contextual display fields only, separate from
|
|
627
|
+
mapping identity refs.
|
|
628
|
+
</p>
|
|
629
|
+
<textarea
|
|
630
|
+
id="metadata-json"
|
|
631
|
+
className="border-input bg-background min-h-32 w-full rounded-md border px-3 py-2 text-sm font-mono"
|
|
632
|
+
value={metadataInput}
|
|
633
|
+
onChange={(event) => setMetadataInput(event.target.value)}
|
|
634
|
+
placeholder='{"firstName":"Alice","company":"Example Co"}'
|
|
635
|
+
/>
|
|
636
|
+
</div>
|
|
637
|
+
|
|
638
|
+
{actionError ? (
|
|
639
|
+
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
|
|
640
|
+
{actionError}
|
|
641
|
+
</div>
|
|
642
|
+
) : null}
|
|
643
|
+
|
|
644
|
+
<div className="flex flex-wrap gap-3">
|
|
645
|
+
<Button onClick={() => void submitForm()} disabled={submitting}>
|
|
646
|
+
{submitting
|
|
647
|
+
? "Saving..."
|
|
648
|
+
: formMode === "create"
|
|
649
|
+
? "Create Mapping"
|
|
650
|
+
: "Save Changes"}
|
|
651
|
+
</Button>
|
|
652
|
+
<Button
|
|
653
|
+
variant="outline"
|
|
654
|
+
onClick={resetForm}
|
|
655
|
+
disabled={submitting}
|
|
656
|
+
>
|
|
657
|
+
Cancel
|
|
658
|
+
</Button>
|
|
659
|
+
</div>
|
|
660
|
+
</CardContent>
|
|
661
|
+
</Card>
|
|
662
|
+
) : null}
|
|
663
|
+
|
|
664
|
+
<Card>
|
|
665
|
+
<CardHeader className="flex flex-row items-center justify-between">
|
|
666
|
+
<CardTitle>Mappings</CardTitle>
|
|
667
|
+
{page ? (
|
|
668
|
+
<p className="text-muted-foreground text-sm">
|
|
669
|
+
{page.total} total mapping{page.total === 1 ? "" : "s"}
|
|
670
|
+
</p>
|
|
671
|
+
) : null}
|
|
672
|
+
</CardHeader>
|
|
673
|
+
<CardContent>
|
|
674
|
+
{mappingsLoading ? (
|
|
675
|
+
<div className="text-muted-foreground text-sm">
|
|
676
|
+
Loading mappings...
|
|
677
|
+
</div>
|
|
678
|
+
) : null}
|
|
679
|
+
|
|
680
|
+
{!mappingsLoading &&
|
|
681
|
+
selectedResource &&
|
|
682
|
+
mappings.length === 0 &&
|
|
683
|
+
search.trim() ? (
|
|
684
|
+
<div className="text-muted-foreground text-sm">
|
|
685
|
+
No mappings match this search for the selected resource.
|
|
686
|
+
</div>
|
|
687
|
+
) : null}
|
|
688
|
+
|
|
689
|
+
{!mappingsLoading &&
|
|
690
|
+
selectedResource &&
|
|
691
|
+
mappings.length === 0 &&
|
|
692
|
+
!search.trim() ? (
|
|
693
|
+
<div className="text-muted-foreground text-sm">
|
|
694
|
+
This resource has no mappings yet. Create the first one to start
|
|
695
|
+
connecting identities across plugs.
|
|
696
|
+
</div>
|
|
697
|
+
) : null}
|
|
698
|
+
|
|
699
|
+
{!mappingsLoading && mappings.length > 0 ? (
|
|
700
|
+
<div className="space-y-4">
|
|
701
|
+
<Table>
|
|
702
|
+
<TableHeader>
|
|
703
|
+
<TableRow>
|
|
704
|
+
<TableHead>Connect Value</TableHead>
|
|
705
|
+
<TableHead>Refs</TableHead>
|
|
706
|
+
<TableHead>Metadata</TableHead>
|
|
707
|
+
<TableHead className="text-right">Actions</TableHead>
|
|
708
|
+
</TableRow>
|
|
709
|
+
</TableHeader>
|
|
710
|
+
<TableBody>
|
|
711
|
+
{mappings.map((mapping) => (
|
|
712
|
+
<TableRow key={mapping.id}>
|
|
713
|
+
<TableCell className="font-medium">
|
|
714
|
+
{mapping.connectValue}
|
|
715
|
+
</TableCell>
|
|
716
|
+
<TableCell>{renderRefsSummary(mapping)}</TableCell>
|
|
717
|
+
<TableCell>{renderMetadataSummary(mapping)}</TableCell>
|
|
718
|
+
<TableCell className="text-right">
|
|
719
|
+
<div className="flex justify-end gap-2">
|
|
720
|
+
<Button
|
|
721
|
+
variant="outline"
|
|
722
|
+
size="sm"
|
|
723
|
+
onClick={() => openEditForm(mapping)}
|
|
724
|
+
>
|
|
725
|
+
Edit
|
|
726
|
+
</Button>
|
|
727
|
+
<Button
|
|
728
|
+
variant="outline"
|
|
729
|
+
size="sm"
|
|
730
|
+
onClick={() => void handleDelete(mapping)}
|
|
731
|
+
>
|
|
732
|
+
Delete
|
|
733
|
+
</Button>
|
|
734
|
+
</div>
|
|
735
|
+
</TableCell>
|
|
736
|
+
</TableRow>
|
|
737
|
+
))}
|
|
738
|
+
</TableBody>
|
|
739
|
+
</Table>
|
|
740
|
+
|
|
741
|
+
{page ? (
|
|
742
|
+
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
743
|
+
<p className="text-muted-foreground text-sm">
|
|
744
|
+
Showing {page.offset + 1}-{page.offset + mappings.length} of{" "}
|
|
745
|
+
{page.total}
|
|
746
|
+
</p>
|
|
747
|
+
<div className="flex gap-2">
|
|
748
|
+
<Button
|
|
749
|
+
variant="outline"
|
|
750
|
+
size="sm"
|
|
751
|
+
disabled={page.offset === 0}
|
|
752
|
+
onClick={() => setOffset(page.prevOffset)}
|
|
753
|
+
>
|
|
754
|
+
Previous
|
|
755
|
+
</Button>
|
|
756
|
+
<Button
|
|
757
|
+
variant="outline"
|
|
758
|
+
size="sm"
|
|
759
|
+
disabled={!page.hasMore}
|
|
760
|
+
onClick={() => setOffset(page.nextOffset)}
|
|
761
|
+
>
|
|
762
|
+
Next
|
|
763
|
+
</Button>
|
|
764
|
+
</div>
|
|
765
|
+
</div>
|
|
766
|
+
) : null}
|
|
767
|
+
</div>
|
|
768
|
+
) : null}
|
|
769
|
+
</CardContent>
|
|
770
|
+
</Card>
|
|
771
|
+
</div>
|
|
772
|
+
);
|
|
773
|
+
}
|