khotan-data 0.1.1 → 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 +29 -0
- package/dist/cli.js +132 -46
- package/dist/factory.cjs +79 -9
- package/dist/factory.cjs.map +1 -1
- package/dist/factory.d.cts +38 -1
- package/dist/factory.d.ts +38 -1
- package/dist/factory.js +79 -10
- package/dist/factory.js.map +1 -1
- package/dist/templates/api-state.tsx +249 -0
- package/dist/templates/debug-index-page.tsx +56 -36
- package/dist/templates/hub.tsx +9 -23
- package/dist/templates/khotan-config.ts +17 -0
- package/dist/templates/mapping-browser.tsx +56 -44
- package/dist/templates/plug-debugger.tsx +15 -7
- package/dist/templates/runs-table.tsx +133 -130
- package/dist/templates/skill-setup.md +37 -2
- 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,249 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// Khotan API helpers — typed fetch + graceful error/empty UI states
|
|
7
|
+
// Generated by khotan CLI · https://github.com/khotan-data
|
|
8
|
+
//
|
|
9
|
+
// This file is yours. Every khotan dashboard component imports `khotanFetch`
|
|
10
|
+
// and `<ApiErrorState>` from here so that 401 (auth), 403, 404, 5xx, and
|
|
11
|
+
// network failures render a simple, consistent UI instead of a blank screen
|
|
12
|
+
// or a thrown error. Restyle it however you like.
|
|
13
|
+
// ============================================================================
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Error thrown by {@link khotanFetch} when a request fails. Carries the HTTP
|
|
17
|
+
* status so the UI can branch on it. `status === 0` means a network-level
|
|
18
|
+
* failure (server unreachable) rather than an HTTP error response.
|
|
19
|
+
*/
|
|
20
|
+
export class KhotanApiError extends Error {
|
|
21
|
+
readonly status: number;
|
|
22
|
+
readonly statusText: string;
|
|
23
|
+
readonly body: unknown;
|
|
24
|
+
|
|
25
|
+
constructor(status: number, statusText: string, body?: unknown) {
|
|
26
|
+
super(
|
|
27
|
+
status === 0
|
|
28
|
+
? "Could not reach the server"
|
|
29
|
+
: `Request failed: ${String(status)} ${statusText}`,
|
|
30
|
+
);
|
|
31
|
+
this.name = "KhotanApiError";
|
|
32
|
+
this.status = status;
|
|
33
|
+
this.statusText = statusText;
|
|
34
|
+
this.body = body;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function isKhotanApiError(error: unknown): error is KhotanApiError {
|
|
39
|
+
return error instanceof KhotanApiError;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* `fetch` wrapper that throws a typed {@link KhotanApiError} on any non-2xx
|
|
44
|
+
* response (or network failure) and returns parsed JSON otherwise. Use it for
|
|
45
|
+
* every call to the khotan API so error handling stays consistent.
|
|
46
|
+
*/
|
|
47
|
+
export async function khotanFetch<T = unknown>(
|
|
48
|
+
input: string,
|
|
49
|
+
init?: RequestInit,
|
|
50
|
+
): Promise<T> {
|
|
51
|
+
let res: Response;
|
|
52
|
+
try {
|
|
53
|
+
res = await fetch(input, init);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
throw new KhotanApiError(
|
|
56
|
+
0,
|
|
57
|
+
"Network Error",
|
|
58
|
+
err instanceof Error ? err.message : undefined,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
let body: unknown;
|
|
64
|
+
try {
|
|
65
|
+
body = await res.clone().json();
|
|
66
|
+
} catch {
|
|
67
|
+
try {
|
|
68
|
+
body = await res.text();
|
|
69
|
+
} catch {
|
|
70
|
+
body = undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
throw new KhotanApiError(res.status, res.statusText, body);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (res.status === 204) return undefined as T;
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
return (await res.json()) as T;
|
|
80
|
+
} catch {
|
|
81
|
+
return undefined as T;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface ErrorCopy {
|
|
86
|
+
/** HTTP status code, or null for non-HTTP failures. */
|
|
87
|
+
code: number | null;
|
|
88
|
+
title: string;
|
|
89
|
+
message: string;
|
|
90
|
+
/** Auth errors (401/403) show a lock icon instead of the alert icon. */
|
|
91
|
+
isAuth: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function describeError(error: unknown): ErrorCopy {
|
|
95
|
+
const status = isKhotanApiError(error) ? error.status : null;
|
|
96
|
+
|
|
97
|
+
if (status === 401 || status === 403) {
|
|
98
|
+
return {
|
|
99
|
+
code: status,
|
|
100
|
+
title: "Access denied",
|
|
101
|
+
message: "You don't have permission to view this.",
|
|
102
|
+
isAuth: true,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if (status === 404) {
|
|
106
|
+
return {
|
|
107
|
+
code: 404,
|
|
108
|
+
title: "Not found",
|
|
109
|
+
message: "This resource doesn't exist.",
|
|
110
|
+
isAuth: false,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (status === 429) {
|
|
114
|
+
return {
|
|
115
|
+
code: 429,
|
|
116
|
+
title: "Too many requests",
|
|
117
|
+
message: "Slow down and try again in a moment.",
|
|
118
|
+
isAuth: false,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
if (status !== null && status >= 500) {
|
|
122
|
+
return {
|
|
123
|
+
code: status,
|
|
124
|
+
title: "Server error",
|
|
125
|
+
message: "Something went wrong on the server.",
|
|
126
|
+
isAuth: false,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
if (status !== null && status >= 400) {
|
|
130
|
+
return {
|
|
131
|
+
code: status,
|
|
132
|
+
title: "Request error",
|
|
133
|
+
message: "The request couldn't be completed.",
|
|
134
|
+
isAuth: false,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
code: null,
|
|
139
|
+
title: "Connection error",
|
|
140
|
+
message: "Couldn't reach the server.",
|
|
141
|
+
isAuth: false,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function LockIcon({ className = "" }: { className?: string }): ReactNode {
|
|
146
|
+
return (
|
|
147
|
+
<svg
|
|
148
|
+
viewBox="0 0 24 24"
|
|
149
|
+
fill="none"
|
|
150
|
+
stroke="currentColor"
|
|
151
|
+
strokeWidth={1.5}
|
|
152
|
+
strokeLinecap="round"
|
|
153
|
+
strokeLinejoin="round"
|
|
154
|
+
aria-hidden="true"
|
|
155
|
+
className={className}
|
|
156
|
+
>
|
|
157
|
+
<rect x="3.5" y="10.5" width="17" height="10" rx="2" />
|
|
158
|
+
<path d="M7.5 10.5V7a4.5 4.5 0 0 1 9 0v3.5" />
|
|
159
|
+
<circle cx="12" cy="15.5" r="1.25" />
|
|
160
|
+
</svg>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function AlertIcon({ className = "" }: { className?: string }): ReactNode {
|
|
165
|
+
return (
|
|
166
|
+
<svg
|
|
167
|
+
viewBox="0 0 24 24"
|
|
168
|
+
fill="none"
|
|
169
|
+
stroke="currentColor"
|
|
170
|
+
strokeWidth={1.5}
|
|
171
|
+
strokeLinecap="round"
|
|
172
|
+
strokeLinejoin="round"
|
|
173
|
+
aria-hidden="true"
|
|
174
|
+
className={className}
|
|
175
|
+
>
|
|
176
|
+
<circle cx="12" cy="12" r="9" />
|
|
177
|
+
<path d="M12 8v5" />
|
|
178
|
+
<path d="M12 16.5h.01" />
|
|
179
|
+
</svg>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface ApiErrorStateProps {
|
|
184
|
+
error: unknown;
|
|
185
|
+
/** Called when the user clicks Retry. Omit to hide the Retry button. */
|
|
186
|
+
onRetry?: () => void;
|
|
187
|
+
/** Render a smaller inline banner (for nested panels) instead of a full card. */
|
|
188
|
+
compact?: boolean;
|
|
189
|
+
className?: string;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Simple UI for a failed khotan API request — shows an icon, the HTTP status,
|
|
194
|
+
* and a short message, plus an optional Retry button. Intentionally minimal.
|
|
195
|
+
*/
|
|
196
|
+
export function ApiErrorState({
|
|
197
|
+
error,
|
|
198
|
+
onRetry,
|
|
199
|
+
compact = false,
|
|
200
|
+
className = "",
|
|
201
|
+
}: ApiErrorStateProps): ReactNode {
|
|
202
|
+
const { code, title, message, isAuth } = describeError(error);
|
|
203
|
+
const Icon = isAuth ? LockIcon : AlertIcon;
|
|
204
|
+
|
|
205
|
+
if (compact) {
|
|
206
|
+
return (
|
|
207
|
+
<div
|
|
208
|
+
role="alert"
|
|
209
|
+
className={`flex flex-wrap items-center justify-between gap-2 rounded-md border border-border bg-muted/30 px-3 py-2 ${className}`}
|
|
210
|
+
>
|
|
211
|
+
<span className="flex items-center gap-2 text-xs font-medium text-foreground">
|
|
212
|
+
<Icon className="h-4 w-4 text-muted-foreground" />
|
|
213
|
+
{code ? `${String(code)} — ${title}` : title}
|
|
214
|
+
</span>
|
|
215
|
+
{onRetry && (
|
|
216
|
+
<button
|
|
217
|
+
type="button"
|
|
218
|
+
onClick={onRetry}
|
|
219
|
+
className="text-xs text-muted-foreground underline-offset-2 hover:underline"
|
|
220
|
+
>
|
|
221
|
+
Retry
|
|
222
|
+
</button>
|
|
223
|
+
)}
|
|
224
|
+
</div>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return (
|
|
229
|
+
<div
|
|
230
|
+
role="alert"
|
|
231
|
+
className={`flex flex-col items-center justify-center rounded-lg border border-border bg-card px-6 py-12 text-center ${className}`}
|
|
232
|
+
>
|
|
233
|
+
<Icon className="h-8 w-8 text-muted-foreground" />
|
|
234
|
+
<p className="mt-3 text-sm font-medium text-foreground">
|
|
235
|
+
{code ? `${String(code)} — ${title}` : title}
|
|
236
|
+
</p>
|
|
237
|
+
<p className="mt-1 text-sm text-muted-foreground">{message}</p>
|
|
238
|
+
{onRetry && (
|
|
239
|
+
<button
|
|
240
|
+
type="button"
|
|
241
|
+
onClick={onRetry}
|
|
242
|
+
className="mt-4 text-xs text-muted-foreground underline-offset-2 hover:underline"
|
|
243
|
+
>
|
|
244
|
+
Retry
|
|
245
|
+
</button>
|
|
246
|
+
)}
|
|
247
|
+
</div>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useEffect, useState } from "react";
|
|
3
|
+
import { useCallback, useEffect, useState } from "react";
|
|
4
4
|
import Link from "next/link";
|
|
5
|
+
import { khotanFetch, ApiErrorState } from "@/components/khotan/api-state";
|
|
5
6
|
|
|
6
7
|
// ============================================================================
|
|
7
8
|
// Debug Index — Lists all registered plugs for debugging
|
|
@@ -23,20 +24,28 @@ export default function DebugIndexPage() {
|
|
|
23
24
|
const [plugs, setPlugs] = useState<Plug[]>([]);
|
|
24
25
|
const [loading, setLoading] = useState(true);
|
|
25
26
|
const [debugEnabled, setDebugEnabled] = useState<boolean | null>(null);
|
|
27
|
+
const [error, setError] = useState<unknown>(null);
|
|
26
28
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
setPlugs(
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
|
|
29
|
+
const load = useCallback(async () => {
|
|
30
|
+
setLoading(true);
|
|
31
|
+
setError(null);
|
|
32
|
+
try {
|
|
33
|
+
const enabled = await fetch("/api/khotan/debug").then((r) => r.ok);
|
|
34
|
+
setDebugEnabled(enabled);
|
|
35
|
+
if (enabled) {
|
|
36
|
+
setPlugs(await khotanFetch<Plug[]>("/api/khotan/plugs"));
|
|
37
|
+
}
|
|
38
|
+
} catch (err) {
|
|
39
|
+
setError(err);
|
|
40
|
+
} finally {
|
|
41
|
+
setLoading(false);
|
|
42
|
+
}
|
|
38
43
|
}, []);
|
|
39
44
|
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
void load();
|
|
47
|
+
}, [load]);
|
|
48
|
+
|
|
40
49
|
if (loading) {
|
|
41
50
|
return (
|
|
42
51
|
<main className="container mx-auto max-w-3xl px-4 py-10">
|
|
@@ -71,31 +80,42 @@ export default function DebugIndexPage() {
|
|
|
71
80
|
</a>
|
|
72
81
|
</div>
|
|
73
82
|
<h1 className="text-2xl font-bold tracking-tight mb-6">Debug</h1>
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
83
|
+
{error ? (
|
|
84
|
+
<ApiErrorState error={error} onRetry={() => void load()} />
|
|
85
|
+
) : (
|
|
86
|
+
<>
|
|
87
|
+
<p className="text-muted-foreground mb-6">
|
|
88
|
+
Select a plug to test requests through its real code path.
|
|
89
|
+
</p>
|
|
90
|
+
<div className="grid gap-3">
|
|
91
|
+
{plugs.map((plug) => (
|
|
92
|
+
<Link
|
|
93
|
+
key={plug.id}
|
|
94
|
+
href={`/debug/${plug.name}`}
|
|
95
|
+
className="flex items-center justify-between rounded-lg border border-border p-4 transition-colors hover:border-foreground/30 hover:bg-muted/50"
|
|
96
|
+
>
|
|
97
|
+
<div>
|
|
98
|
+
<p className="font-medium">{plug.name}</p>
|
|
99
|
+
<p className="text-xs text-muted-foreground truncate">
|
|
100
|
+
{plug.baseUrl}
|
|
101
|
+
</p>
|
|
102
|
+
</div>
|
|
103
|
+
<div className="flex items-center gap-2">
|
|
104
|
+
<span className="text-xs text-muted-foreground">
|
|
105
|
+
{plug.authType}
|
|
106
|
+
</span>
|
|
107
|
+
<span className="text-muted-foreground">→</span>
|
|
108
|
+
</div>
|
|
109
|
+
</Link>
|
|
110
|
+
))}
|
|
111
|
+
{plugs.length === 0 && (
|
|
112
|
+
<p className="text-sm text-muted-foreground">
|
|
113
|
+
No plugs registered yet.
|
|
88
114
|
</p>
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
</span>
|
|
94
|
-
<span className="text-muted-foreground">→</span>
|
|
95
|
-
</div>
|
|
96
|
-
</Link>
|
|
97
|
-
))}
|
|
98
|
-
</div>
|
|
115
|
+
)}
|
|
116
|
+
</div>
|
|
117
|
+
</>
|
|
118
|
+
)}
|
|
99
119
|
</main>
|
|
100
120
|
);
|
|
101
121
|
}
|
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
|
|
@@ -108,7 +109,7 @@ export function KhotanHub({
|
|
|
108
109
|
const [flows, setFlows] = useState<Flow[]>([]);
|
|
109
110
|
const [webhookHandlers, setWebhookHandlers] = useState<WebhookHandler[]>([]);
|
|
110
111
|
const [loading, setLoading] = useState(true);
|
|
111
|
-
const [error, setError] = useState<
|
|
112
|
+
const [error, setError] = useState<unknown>(null);
|
|
112
113
|
const [selectedPlugId, setSelectedPlugId] = useState<string | null>(null);
|
|
113
114
|
const [debugEnabled, setDebugEnabled] = useState(false);
|
|
114
115
|
|
|
@@ -122,17 +123,14 @@ export function KhotanHub({
|
|
|
122
123
|
setLoading(true);
|
|
123
124
|
setError(null);
|
|
124
125
|
try {
|
|
125
|
-
const [
|
|
126
|
-
|
|
127
|
-
|
|
126
|
+
const [plugsData, flowsData] = await Promise.all([
|
|
127
|
+
khotanFetch<Plug[]>("/api/khotan/plugs"),
|
|
128
|
+
khotanFetch<Flow[]>("/api/khotan/flows"),
|
|
128
129
|
]);
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
setPlugs(await plugsRes.json());
|
|
133
|
-
setFlows(await flowsRes.json());
|
|
130
|
+
setPlugs(plugsData);
|
|
131
|
+
setFlows(flowsData);
|
|
134
132
|
} catch (err) {
|
|
135
|
-
setError(err
|
|
133
|
+
setError(err);
|
|
136
134
|
} finally {
|
|
137
135
|
setLoading(false);
|
|
138
136
|
}
|
|
@@ -201,19 +199,7 @@ export function KhotanHub({
|
|
|
201
199
|
}
|
|
202
200
|
|
|
203
201
|
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
|
-
);
|
|
202
|
+
return <ApiErrorState error={error} onRetry={fetchData} />;
|
|
217
203
|
}
|
|
218
204
|
|
|
219
205
|
if (plugs.length === 0) {
|
|
@@ -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: [
|
|
@@ -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
|