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
|
@@ -5,6 +5,7 @@ import { Badge } from "@/components/ui/badge";
|
|
|
5
5
|
import { Button } from "@/components/ui/button";
|
|
6
6
|
import { Input } from "@/components/ui/input";
|
|
7
7
|
import { Label } from "@/components/ui/label";
|
|
8
|
+
import { khotanFetch, isKhotanApiError, ApiErrorState } from "./api-state";
|
|
8
9
|
|
|
9
10
|
// ============================================================================
|
|
10
11
|
// Plug Debugger — Lightweight Postman for your plugs
|
|
@@ -331,6 +332,7 @@ export function PlugDebugger({
|
|
|
331
332
|
}: PlugDebuggerProps) {
|
|
332
333
|
const [meta, setMeta] = useState<PlugMeta | null>(null);
|
|
333
334
|
const [loading, setLoading] = useState(true);
|
|
335
|
+
const [metaError, setMetaError] = useState<unknown>(null);
|
|
334
336
|
|
|
335
337
|
const [method, setMethod] = useState<string>("GET");
|
|
336
338
|
const [path, setPath] = useState("");
|
|
@@ -353,15 +355,17 @@ export function PlugDebugger({
|
|
|
353
355
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
354
356
|
|
|
355
357
|
const fetchMeta = useCallback(async () => {
|
|
358
|
+
setLoading(true);
|
|
359
|
+
setMetaError(null);
|
|
356
360
|
try {
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
setMeta(null);
|
|
360
|
-
return;
|
|
361
|
-
}
|
|
362
|
-
setMeta(await res.json());
|
|
363
|
-
} catch {
|
|
361
|
+
setMeta(await khotanFetch<PlugMeta>(`${basePath}/debug/${plugName}`));
|
|
362
|
+
} catch (err) {
|
|
364
363
|
setMeta(null);
|
|
364
|
+
// A 404 means debug is off or the plug isn't registered — handled by the
|
|
365
|
+
// dedicated "not available" message below. Surface everything else.
|
|
366
|
+
if (!(isKhotanApiError(err) && err.status === 404)) {
|
|
367
|
+
setMetaError(err);
|
|
368
|
+
}
|
|
365
369
|
} finally {
|
|
366
370
|
setLoading(false);
|
|
367
371
|
}
|
|
@@ -401,6 +405,10 @@ export function PlugDebugger({
|
|
|
401
405
|
);
|
|
402
406
|
}
|
|
403
407
|
|
|
408
|
+
if (metaError) {
|
|
409
|
+
return <ApiErrorState error={metaError} onRetry={() => void fetchMeta()} />;
|
|
410
|
+
}
|
|
411
|
+
|
|
404
412
|
if (!meta) {
|
|
405
413
|
return (
|
|
406
414
|
<div className="rounded-lg border border-border p-6 text-center">
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { Fragment, useCallback, useEffect, useState } from "react";
|
|
4
4
|
import { RefreshCw } from "lucide-react";
|
|
5
|
+
import { khotanFetch, ApiErrorState } from "./api-state";
|
|
5
6
|
import { Badge } from "@/components/ui/badge";
|
|
6
7
|
import { Button } from "@/components/ui/button";
|
|
7
8
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
@@ -359,7 +360,7 @@ export function KhotanRunsTable({ pageSize = 10 }: { pageSize?: number } = {}) {
|
|
|
359
360
|
const [data, setData] = useState<PageResponse<RunLogItem> | null>(null);
|
|
360
361
|
const [offset, setOffset] = useState(0);
|
|
361
362
|
const [loading, setLoading] = useState(true);
|
|
362
|
-
const [error, setError] = useState<
|
|
363
|
+
const [error, setError] = useState<unknown>(null);
|
|
363
364
|
const [refreshKey, setRefreshKey] = useState(0);
|
|
364
365
|
const [expandedRunId, setExpandedRunId] = useState<string | null>(null);
|
|
365
366
|
const [streamingEnabled, setStreamingEnabled] = useState(false);
|
|
@@ -378,20 +379,16 @@ export function KhotanRunsTable({ pageSize = 10 }: { pageSize?: number } = {}) {
|
|
|
378
379
|
setLoading(true);
|
|
379
380
|
setError(null);
|
|
380
381
|
try {
|
|
381
|
-
const
|
|
382
|
+
const json = await khotanFetch<PageResponse<RunLogItem>>(
|
|
382
383
|
`/api/khotan/runs?limit=${String(pageSize)}&offset=${String(offset)}`,
|
|
383
384
|
);
|
|
384
|
-
if (!res.ok) {
|
|
385
|
-
throw new Error("Failed to load runs");
|
|
386
|
-
}
|
|
387
|
-
const json = (await res.json()) as PageResponse<RunLogItem>;
|
|
388
385
|
if (!cancelled) {
|
|
389
386
|
setData(json);
|
|
390
387
|
setLastUpdatedAt(new Date().toISOString());
|
|
391
388
|
}
|
|
392
389
|
} catch (err) {
|
|
393
390
|
if (!cancelled) {
|
|
394
|
-
setError(err
|
|
391
|
+
setError(err);
|
|
395
392
|
}
|
|
396
393
|
} finally {
|
|
397
394
|
if (!cancelled) {
|
|
@@ -454,138 +451,144 @@ export function KhotanRunsTable({ pageSize = 10 }: { pageSize?: number } = {}) {
|
|
|
454
451
|
</CardHeader>
|
|
455
452
|
<CardContent className="space-y-4">
|
|
456
453
|
{error ? (
|
|
457
|
-
<
|
|
458
|
-
{error}
|
|
459
|
-
|
|
454
|
+
<ApiErrorState
|
|
455
|
+
error={error}
|
|
456
|
+
onRetry={() => setRefreshKey((v) => v + 1)}
|
|
457
|
+
compact
|
|
458
|
+
/>
|
|
460
459
|
) : null}
|
|
461
460
|
|
|
462
|
-
|
|
463
|
-
<
|
|
464
|
-
<
|
|
465
|
-
<TableHead>Started</TableHead>
|
|
466
|
-
<TableHead>Status</TableHead>
|
|
467
|
-
<TableHead>Source</TableHead>
|
|
468
|
-
<TableHead>Plug</TableHead>
|
|
469
|
-
<TableHead>Run Type</TableHead>
|
|
470
|
-
<TableHead>Counts</TableHead>
|
|
471
|
-
<TableHead>Workflow</TableHead>
|
|
472
|
-
<TableHead />
|
|
473
|
-
</TableRow>
|
|
474
|
-
</TableHeader>
|
|
475
|
-
<TableBody>
|
|
476
|
-
{loading ? (
|
|
461
|
+
{error ? null : (
|
|
462
|
+
<Table>
|
|
463
|
+
<TableHeader>
|
|
477
464
|
<TableRow>
|
|
478
|
-
<
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
>
|
|
482
|
-
|
|
483
|
-
</
|
|
465
|
+
<TableHead>Started</TableHead>
|
|
466
|
+
<TableHead>Status</TableHead>
|
|
467
|
+
<TableHead>Source</TableHead>
|
|
468
|
+
<TableHead>Plug</TableHead>
|
|
469
|
+
<TableHead>Run Type</TableHead>
|
|
470
|
+
<TableHead>Counts</TableHead>
|
|
471
|
+
<TableHead>Workflow</TableHead>
|
|
472
|
+
<TableHead />
|
|
484
473
|
</TableRow>
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
{statusLabel[item.status]}
|
|
500
|
-
</Badge>
|
|
501
|
-
{item.error ? (
|
|
502
|
-
<div
|
|
503
|
-
className="mt-1 max-w-56 truncate text-xs text-destructive"
|
|
504
|
-
title={item.error}
|
|
505
|
-
>
|
|
506
|
-
{item.error}
|
|
507
|
-
</div>
|
|
508
|
-
) : null}
|
|
509
|
-
</TableCell>
|
|
510
|
-
<TableCell className="font-medium">
|
|
511
|
-
{formatSource(item)}
|
|
512
|
-
</TableCell>
|
|
513
|
-
<TableCell className="text-muted-foreground">
|
|
514
|
-
{item.plugName ?? "-"}
|
|
515
|
-
</TableCell>
|
|
516
|
-
<TableCell className="font-mono text-xs">
|
|
517
|
-
{item.runType}
|
|
518
|
-
</TableCell>
|
|
519
|
-
<TableCell className="max-w-64 text-xs text-muted-foreground">
|
|
520
|
-
{formatCounts(item)}
|
|
521
|
-
</TableCell>
|
|
522
|
-
<TableCell className="font-mono text-xs text-muted-foreground">
|
|
523
|
-
{item.workflowRunId ?? "-"}
|
|
524
|
-
</TableCell>
|
|
525
|
-
<TableCell className="text-right">
|
|
526
|
-
<Button
|
|
527
|
-
variant="outline"
|
|
528
|
-
size="sm"
|
|
529
|
-
onClick={() =>
|
|
530
|
-
setExpandedRunId((current) =>
|
|
531
|
-
current === item.id ? null : item.id,
|
|
532
|
-
)
|
|
533
|
-
}
|
|
534
|
-
>
|
|
535
|
-
{expandedRunId === item.id ? "Hide" : "Details"}
|
|
536
|
-
</Button>
|
|
537
|
-
</TableCell>
|
|
538
|
-
</TableRow>
|
|
539
|
-
{expandedRunId === item.id ? (
|
|
474
|
+
</TableHeader>
|
|
475
|
+
<TableBody>
|
|
476
|
+
{loading ? (
|
|
477
|
+
<TableRow>
|
|
478
|
+
<TableCell
|
|
479
|
+
colSpan={8}
|
|
480
|
+
className="text-sm text-muted-foreground"
|
|
481
|
+
>
|
|
482
|
+
Loading runs...
|
|
483
|
+
</TableCell>
|
|
484
|
+
</TableRow>
|
|
485
|
+
) : data?.items.length ? (
|
|
486
|
+
data.items.map((item) => (
|
|
487
|
+
<Fragment key={item.id}>
|
|
540
488
|
<TableRow>
|
|
541
|
-
<TableCell
|
|
542
|
-
<
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
489
|
+
<TableCell className="text-sm text-muted-foreground">
|
|
490
|
+
<div>{formatDateTime(item.startedAt)}</div>
|
|
491
|
+
<div className="text-xs">
|
|
492
|
+
{item.completedAt
|
|
493
|
+
? `completed ${formatDateTime(item.completedAt)}`
|
|
494
|
+
: "in progress"}
|
|
495
|
+
</div>
|
|
496
|
+
</TableCell>
|
|
497
|
+
<TableCell>
|
|
498
|
+
<Badge variant={statusVariant[item.status]}>
|
|
499
|
+
{statusLabel[item.status]}
|
|
500
|
+
</Badge>
|
|
501
|
+
{item.error ? (
|
|
502
|
+
<div
|
|
503
|
+
className="mt-1 max-w-56 truncate text-xs text-destructive"
|
|
504
|
+
title={item.error}
|
|
505
|
+
>
|
|
506
|
+
{item.error}
|
|
507
|
+
</div>
|
|
508
|
+
) : null}
|
|
509
|
+
</TableCell>
|
|
510
|
+
<TableCell className="font-medium">
|
|
511
|
+
{formatSource(item)}
|
|
512
|
+
</TableCell>
|
|
513
|
+
<TableCell className="text-muted-foreground">
|
|
514
|
+
{item.plugName ?? "-"}
|
|
515
|
+
</TableCell>
|
|
516
|
+
<TableCell className="font-mono text-xs">
|
|
517
|
+
{item.runType}
|
|
518
|
+
</TableCell>
|
|
519
|
+
<TableCell className="max-w-64 text-xs text-muted-foreground">
|
|
520
|
+
{formatCounts(item)}
|
|
521
|
+
</TableCell>
|
|
522
|
+
<TableCell className="font-mono text-xs text-muted-foreground">
|
|
523
|
+
{item.workflowRunId ?? "-"}
|
|
524
|
+
</TableCell>
|
|
525
|
+
<TableCell className="text-right">
|
|
526
|
+
<Button
|
|
527
|
+
variant="outline"
|
|
528
|
+
size="sm"
|
|
529
|
+
onClick={() =>
|
|
530
|
+
setExpandedRunId((current) =>
|
|
531
|
+
current === item.id ? null : item.id,
|
|
532
|
+
)
|
|
533
|
+
}
|
|
534
|
+
>
|
|
535
|
+
{expandedRunId === item.id ? "Hide" : "Details"}
|
|
536
|
+
</Button>
|
|
548
537
|
</TableCell>
|
|
549
538
|
</TableRow>
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
539
|
+
{expandedRunId === item.id ? (
|
|
540
|
+
<TableRow>
|
|
541
|
+
<TableCell colSpan={8}>
|
|
542
|
+
<RunDetails
|
|
543
|
+
run={item}
|
|
544
|
+
streamingEnabled={streamingEnabled}
|
|
545
|
+
onChanged={() => setRefreshKey((v) => v + 1)}
|
|
546
|
+
onStreamInbound={pulseLiveIndicator}
|
|
547
|
+
/>
|
|
548
|
+
</TableCell>
|
|
549
|
+
</TableRow>
|
|
550
|
+
) : null}
|
|
551
|
+
</Fragment>
|
|
552
|
+
))
|
|
553
|
+
) : (
|
|
554
|
+
<TableRow>
|
|
555
|
+
<TableCell
|
|
556
|
+
colSpan={8}
|
|
557
|
+
className="text-sm text-muted-foreground"
|
|
558
|
+
>
|
|
559
|
+
No runs recorded yet.
|
|
560
|
+
</TableCell>
|
|
561
|
+
</TableRow>
|
|
562
|
+
)}
|
|
563
|
+
</TableBody>
|
|
564
|
+
</Table>
|
|
565
|
+
)}
|
|
565
566
|
|
|
566
|
-
|
|
567
|
-
<
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
<
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
567
|
+
{error ? null : (
|
|
568
|
+
<div className="flex items-center justify-between gap-3">
|
|
569
|
+
<p className="text-sm text-muted-foreground">
|
|
570
|
+
Page {Math.floor(offset / pageSize) + 1}
|
|
571
|
+
</p>
|
|
572
|
+
<div className="flex items-center gap-2">
|
|
573
|
+
<Button
|
|
574
|
+
variant="outline"
|
|
575
|
+
size="sm"
|
|
576
|
+
disabled={offset === 0 || loading}
|
|
577
|
+
onClick={() => setOffset(Math.max(offset - pageSize, 0))}
|
|
578
|
+
>
|
|
579
|
+
Previous
|
|
580
|
+
</Button>
|
|
581
|
+
<Button
|
|
582
|
+
variant="outline"
|
|
583
|
+
size="sm"
|
|
584
|
+
disabled={!data?.page.hasMore || loading}
|
|
585
|
+
onClick={() => setOffset(offset + pageSize)}
|
|
586
|
+
>
|
|
587
|
+
Next
|
|
588
|
+
</Button>
|
|
589
|
+
</div>
|
|
587
590
|
</div>
|
|
588
|
-
|
|
591
|
+
)}
|
|
589
592
|
</CardContent>
|
|
590
593
|
</Card>
|
|
591
594
|
);
|
|
@@ -90,9 +90,44 @@ The schema command auto-detects your Drizzle schema directory, updates `drizzle.
|
|
|
90
90
|
|----------|----------|---------|
|
|
91
91
|
| `DATABASE_URL` | Yes | Postgres connection (used by Drizzle) |
|
|
92
92
|
| `KHOTAN_SECRET` | For variables | AES-256-GCM key for encrypting plug vars |
|
|
93
|
-
| `KHOTAN_DEBUG` | For debugging | Enables `/debug/*` routes and the `plug` CLI (`probe` alias) |
|
|
93
|
+
| `KHOTAN_DEBUG` | For debugging | Enables `/debug/*` routes and the `plug` CLI (`probe` alias). Automatically disabled when `NODE_ENV=production` |
|
|
94
94
|
| `KHOTAN_WEBHOOK_URL` | For webhooks | Public URL for wire callbacks |
|
|
95
|
-
| `CRON_SECRET` | For production cron | Protects the built-in `/api/khotan/cron` dispatcher route |
|
|
95
|
+
| `CRON_SECRET` | For production cron | Protects the built-in `/api/khotan/cron` dispatcher route. The route fails closed in production when this is unset |
|
|
96
|
+
|
|
97
|
+
## Securing the Management API
|
|
98
|
+
|
|
99
|
+
The management API (`/api/khotan/*`) and the Hub dashboard expose plug
|
|
100
|
+
credentials and operational controls. **They are public unless you gate them.**
|
|
101
|
+
|
|
102
|
+
Pass an `authorize` hook to `khotan({ ... })`. It receives the raw `Request` and
|
|
103
|
+
returns `true` to allow the request or `false` to reject it with `401`. It
|
|
104
|
+
composes directly with session libraries like better-auth:
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
import { khotan, drizzleAdapter } from "khotan-data/factory";
|
|
108
|
+
import { auth } from "@/lib/auth";
|
|
109
|
+
import { db } from "@/db";
|
|
110
|
+
|
|
111
|
+
const khotanData = khotan({
|
|
112
|
+
adapter: drizzleAdapter(db),
|
|
113
|
+
authorize: async (request) => {
|
|
114
|
+
const session = await auth.api.getSession({ headers: request.headers });
|
|
115
|
+
return Boolean(session?.user); // or: session?.user?.role === "admin"
|
|
116
|
+
},
|
|
117
|
+
plugs: [/* ... */],
|
|
118
|
+
});
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Notes:
|
|
122
|
+
- `authorize` is **not** a replacement for `KHOTAN_SECRET` — that key only
|
|
123
|
+
encrypts credentials at rest, it does not authenticate requests.
|
|
124
|
+
- Inbound webhooks (`POST /webhook/:plug`, verified per-plug via `onVerify`),
|
|
125
|
+
the cron dispatcher (`CRON_SECRET`), and debug routes (`KHOTAN_DEBUG`,
|
|
126
|
+
non-production only) are exempt from `authorize` automatically.
|
|
127
|
+
- Also protect the Hub dashboard page (e.g. `/config`) with your app's
|
|
128
|
+
middleware — `authorize` only guards the API, not your React pages.
|
|
129
|
+
- Without `authorize`, khotan logs a startup warning. Always configure it
|
|
130
|
+
before deploying.
|
|
96
131
|
|
|
97
132
|
## Next.js Config
|
|
98
133
|
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type NodeProps,
|
|
19
19
|
} from "@xyflow/react";
|
|
20
20
|
import "@xyflow/react/dist/style.css";
|
|
21
|
+
import { khotanFetch, ApiErrorState } from "./api-state";
|
|
21
22
|
import { Badge } from "@/components/ui/badge";
|
|
22
23
|
import {
|
|
23
24
|
Card,
|
|
@@ -1056,8 +1057,9 @@ function TopologyCanvasInner() {
|
|
|
1056
1057
|
const [nodes, setNodes, onNodesChange] = useNodesState<TopologyNodeData>([]);
|
|
1057
1058
|
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
|
1058
1059
|
const [loading, setLoading] = useState(true);
|
|
1059
|
-
const [error, setError] = useState<
|
|
1060
|
+
const [error, setError] = useState<unknown>(null);
|
|
1060
1061
|
const [lastUpdatedAt, setLastUpdatedAt] = useState<string | null>(null);
|
|
1062
|
+
const [refreshKey, setRefreshKey] = useState(0);
|
|
1061
1063
|
|
|
1062
1064
|
useEffect(() => {
|
|
1063
1065
|
let cancelled = false;
|
|
@@ -1068,30 +1070,22 @@ function TopologyCanvasInner() {
|
|
|
1068
1070
|
setError(null);
|
|
1069
1071
|
}
|
|
1070
1072
|
|
|
1071
|
-
const [
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1073
|
+
const [plugs, flows, runsRaw] = await Promise.all([
|
|
1074
|
+
khotanFetch<PlugRecord[]>("/api/khotan/plugs"),
|
|
1075
|
+
khotanFetch<FlowRecord[]>("/api/khotan/flows"),
|
|
1076
|
+
khotanFetch<unknown>("/api/khotan/runs?limit=100"),
|
|
1075
1077
|
]);
|
|
1076
1078
|
|
|
1077
|
-
|
|
1078
|
-
throw new Error("Failed to load topology data from /api/khotan");
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
const plugs = (await plugsRes.json()) as PlugRecord[];
|
|
1082
|
-
const flows = (await flowsRes.json()) as FlowRecord[];
|
|
1083
|
-
const runs = normalizeRuns(await runsRes.json());
|
|
1079
|
+
const runs = normalizeRuns(runsRaw);
|
|
1084
1080
|
|
|
1085
1081
|
const webhookGroups = await Promise.all(
|
|
1086
1082
|
plugs.map(async (plug) => {
|
|
1087
1083
|
try {
|
|
1088
|
-
const
|
|
1084
|
+
const handlers = await khotanFetch<
|
|
1085
|
+
Array<Omit<WebhookHandlerRecord, "plugName">>
|
|
1086
|
+
>(
|
|
1089
1087
|
`/api/khotan/webhook-handlers/${encodeURIComponent(plug.name)}`,
|
|
1090
1088
|
);
|
|
1091
|
-
if (!res.ok) return [] as WebhookHandlerRecord[];
|
|
1092
|
-
const handlers = (await res.json()) as Array<
|
|
1093
|
-
Omit<WebhookHandlerRecord, "plugName">
|
|
1094
|
-
>;
|
|
1095
1089
|
return handlers.map((handler) => ({
|
|
1096
1090
|
...handler,
|
|
1097
1091
|
plugName: plug.name,
|
|
@@ -1113,11 +1107,7 @@ function TopologyCanvasInner() {
|
|
|
1113
1107
|
setLastUpdatedAt(new Date().toISOString());
|
|
1114
1108
|
} catch (loadError) {
|
|
1115
1109
|
if (!cancelled) {
|
|
1116
|
-
setError(
|
|
1117
|
-
loadError instanceof Error
|
|
1118
|
-
? loadError.message
|
|
1119
|
-
: "Unknown topology load failure",
|
|
1120
|
-
);
|
|
1110
|
+
setError(loadError);
|
|
1121
1111
|
}
|
|
1122
1112
|
} finally {
|
|
1123
1113
|
if (!cancelled) {
|
|
@@ -1135,7 +1125,7 @@ function TopologyCanvasInner() {
|
|
|
1135
1125
|
cancelled = true;
|
|
1136
1126
|
window.clearInterval(interval);
|
|
1137
1127
|
};
|
|
1138
|
-
}, []);
|
|
1128
|
+
}, [refreshKey]);
|
|
1139
1129
|
|
|
1140
1130
|
const model = useMemo(() => {
|
|
1141
1131
|
return snapshot ? buildTopologyModel(snapshot) : null;
|
|
@@ -1180,19 +1170,18 @@ function TopologyCanvasInner() {
|
|
|
1180
1170
|
|
|
1181
1171
|
if (error) {
|
|
1182
1172
|
return (
|
|
1183
|
-
<Card className="border-
|
|
1173
|
+
<Card className="border-white/70 bg-white/80 shadow-xl backdrop-blur">
|
|
1184
1174
|
<CardHeader>
|
|
1185
1175
|
<CardTitle>Topology Canvas</CardTitle>
|
|
1186
1176
|
<CardDescription>
|
|
1187
1177
|
Could not load the graph from the local Khotan API.
|
|
1188
1178
|
</CardDescription>
|
|
1189
1179
|
</CardHeader>
|
|
1190
|
-
<CardContent
|
|
1191
|
-
<
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
</p>
|
|
1180
|
+
<CardContent>
|
|
1181
|
+
<ApiErrorState
|
|
1182
|
+
error={error}
|
|
1183
|
+
onRetry={() => setRefreshKey((v) => v + 1)}
|
|
1184
|
+
/>
|
|
1196
1185
|
</CardContent>
|
|
1197
1186
|
</Card>
|
|
1198
1187
|
);
|
|
@@ -6,6 +6,7 @@ import { Badge } from "@/components/ui/badge";
|
|
|
6
6
|
import { Button } from "@/components/ui/button";
|
|
7
7
|
import { Input } from "@/components/ui/input";
|
|
8
8
|
import { Label } from "@/components/ui/label";
|
|
9
|
+
import { khotanFetch, ApiErrorState } from "./api-state";
|
|
9
10
|
|
|
10
11
|
// ============================================================================
|
|
11
12
|
// Var Panel — UI for managing plug variables
|
|
@@ -44,6 +45,7 @@ export function VarPanel({
|
|
|
44
45
|
const [configured, setConfigured] = useState(false);
|
|
45
46
|
const [loading, setLoading] = useState(true);
|
|
46
47
|
const [saving, setSaving] = useState(false);
|
|
48
|
+
const [loadError, setLoadError] = useState<unknown>(null);
|
|
47
49
|
const [error, setError] = useState<string | null>(null);
|
|
48
50
|
const [success, setSuccess] = useState<string | null>(null);
|
|
49
51
|
const [editing, setEditing] = useState(false);
|
|
@@ -52,21 +54,23 @@ export function VarPanel({
|
|
|
52
54
|
);
|
|
53
55
|
|
|
54
56
|
const fetchVariables = useCallback(async () => {
|
|
57
|
+
setLoading(true);
|
|
58
|
+
setLoadError(null);
|
|
55
59
|
try {
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
62
|
-
const data = await res.json();
|
|
60
|
+
const data = await khotanFetch<{
|
|
61
|
+
fields?: VarField[];
|
|
62
|
+
values?: Record<string, string>;
|
|
63
|
+
configured?: boolean;
|
|
64
|
+
}>(`${basePath}/variables/${plugName}`);
|
|
63
65
|
setFields((data.fields ?? []).filter((f: VarField) => !f.hidden));
|
|
64
66
|
setValues(data.values ?? {});
|
|
65
67
|
setConfigured(data.configured ?? false);
|
|
66
|
-
setFormValues(data.configured ? data.values : {});
|
|
68
|
+
setFormValues(data.configured ? (data.values ?? {}) : {});
|
|
67
69
|
setError(null);
|
|
68
|
-
} catch {
|
|
69
|
-
|
|
70
|
+
} catch (err) {
|
|
71
|
+
setFields([]);
|
|
72
|
+
setConfigured(false);
|
|
73
|
+
setLoadError(err);
|
|
70
74
|
} finally {
|
|
71
75
|
setLoading(false);
|
|
72
76
|
}
|
|
@@ -144,6 +148,25 @@ export function VarPanel({
|
|
|
144
148
|
);
|
|
145
149
|
}
|
|
146
150
|
|
|
151
|
+
if (loadError) {
|
|
152
|
+
return (
|
|
153
|
+
<Card>
|
|
154
|
+
<CardHeader className="pb-2">
|
|
155
|
+
<CardTitle className="text-sm font-medium capitalize">
|
|
156
|
+
{displayName} Variables
|
|
157
|
+
</CardTitle>
|
|
158
|
+
</CardHeader>
|
|
159
|
+
<CardContent>
|
|
160
|
+
<ApiErrorState
|
|
161
|
+
error={loadError}
|
|
162
|
+
onRetry={() => void fetchVariables()}
|
|
163
|
+
compact
|
|
164
|
+
/>
|
|
165
|
+
</CardContent>
|
|
166
|
+
</Card>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
147
170
|
if (fields.length === 0) return null;
|
|
148
171
|
|
|
149
172
|
return (
|