khotan-data 0.0.1 → 0.1.1

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.
Files changed (55) hide show
  1. package/AGENTS.md +54 -0
  2. package/README.md +117 -1
  3. package/dist/cli.js +2869 -0
  4. package/dist/factory.cjs +3303 -0
  5. package/dist/factory.cjs.map +1 -0
  6. package/dist/factory.d.cts +662 -0
  7. package/dist/factory.d.ts +662 -0
  8. package/dist/factory.js +3292 -0
  9. package/dist/factory.js.map +1 -0
  10. package/dist/plug-client.cjs +99 -0
  11. package/dist/plug-client.cjs.map +1 -0
  12. package/dist/plug-client.d.cts +71 -0
  13. package/dist/plug-client.d.ts +71 -0
  14. package/dist/plug-client.js +96 -0
  15. package/dist/plug-client.js.map +1 -0
  16. package/dist/templates/agent-skill.md +73 -0
  17. package/dist/templates/agents.md +41 -0
  18. package/dist/templates/cache.example.ts +11 -0
  19. package/dist/templates/cache.ts +58 -0
  20. package/dist/templates/catch.example.ts +36 -0
  21. package/dist/templates/catch.ts +119 -0
  22. package/dist/templates/config-page.tsx +20 -0
  23. package/dist/templates/debug-index-page.tsx +101 -0
  24. package/dist/templates/debug-page.tsx +48 -0
  25. package/dist/templates/graph-page.tsx +11 -0
  26. package/dist/templates/hub.tsx +450 -0
  27. package/dist/templates/inflow.example.ts +61 -0
  28. package/dist/templates/inflow.ts +98 -0
  29. package/dist/templates/khotan-config.ts +49 -0
  30. package/dist/templates/khotan-route.ts +13 -0
  31. package/dist/templates/logs-page.tsx +9 -0
  32. package/dist/templates/logs.tsx +20 -0
  33. package/dist/templates/mapping-browser.tsx +761 -0
  34. package/dist/templates/mappings-page.tsx +9 -0
  35. package/dist/templates/outflow.example.ts +52 -0
  36. package/dist/templates/outflow.ts +90 -0
  37. package/dist/templates/pass.example.ts +51 -0
  38. package/dist/templates/pass.ts +134 -0
  39. package/dist/templates/plug-debugger.tsx +1185 -0
  40. package/dist/templates/plug.example.ts +93 -0
  41. package/dist/templates/plug.ts +806 -0
  42. package/dist/templates/relay.example.ts +71 -0
  43. package/dist/templates/relay.ts +104 -0
  44. package/dist/templates/runs-table.tsx +592 -0
  45. package/dist/templates/schema.ts +505 -0
  46. package/dist/templates/skill-dashboard.md +144 -0
  47. package/dist/templates/skill-plug.md +216 -0
  48. package/dist/templates/skill-setup.md +161 -0
  49. package/dist/templates/skill-webhook.md +196 -0
  50. package/dist/templates/topology-canvas.tsx +1406 -0
  51. package/dist/templates/var-panel.tsx +276 -0
  52. package/dist/templates/webhook-events-table.tsx +241 -0
  53. package/dist/templates/wire-panel.tsx +216 -0
  54. package/dist/templates/wire.ts +155 -0
  55. package/package.json +46 -5
@@ -0,0 +1,1406 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useState } from "react";
4
+ import type { CSSProperties } from "react";
5
+ import {
6
+ Background,
7
+ Controls,
8
+ Handle,
9
+ MarkerType,
10
+ MiniMap,
11
+ Position,
12
+ ReactFlow,
13
+ ReactFlowProvider,
14
+ useEdgesState,
15
+ useNodesState,
16
+ type Edge,
17
+ type Node,
18
+ type NodeProps,
19
+ } from "@xyflow/react";
20
+ import "@xyflow/react/dist/style.css";
21
+ import { Badge } from "@/components/ui/badge";
22
+ import {
23
+ Card,
24
+ CardContent,
25
+ CardDescription,
26
+ CardHeader,
27
+ CardTitle,
28
+ } from "@/components/ui/card";
29
+
30
+ type FlowType = "inflow" | "outflow" | "relay";
31
+ type WebhookType = "catch" | "pass";
32
+ type RunStatus =
33
+ | "pending"
34
+ | "running"
35
+ | "completed"
36
+ | "partial"
37
+ | "failed"
38
+ | "cancelled"
39
+ | null;
40
+ type NodeCategory = "database" | "plug" | "flow" | "webhook";
41
+ type EdgeCategory = "inflow" | "outflow" | "relay" | "catch" | "pass";
42
+ type NodeHealth = "idle" | "active" | "failed";
43
+ type NodeLane = "plug" | "flow" | "database" | "webhook" | "destination";
44
+
45
+ interface PlugRecord {
46
+ id: string;
47
+ name: string;
48
+ baseUrl: string;
49
+ authType: string;
50
+ enabled: boolean;
51
+ }
52
+
53
+ interface FlowRecord {
54
+ id: string;
55
+ plugId: string;
56
+ name: string;
57
+ type: FlowType;
58
+ enabled: boolean;
59
+ schedule?: string | null;
60
+ plugName?: string | null;
61
+ lastRunStatus?: RunStatus;
62
+ lastRunAt?: string | null;
63
+ to?: string | null;
64
+ }
65
+
66
+ interface WebhookHandlerRecord {
67
+ id: string;
68
+ name: string;
69
+ type: WebhookType;
70
+ enabled: boolean;
71
+ destinationPlugId: string | null;
72
+ events?: string[] | null;
73
+ lastRunStatus?: RunStatus;
74
+ lastRunAt?: string | null;
75
+ plugName: string;
76
+ }
77
+
78
+ interface RunRecord {
79
+ id: string;
80
+ status: Exclude<RunStatus, null>;
81
+ sourceType: "flow" | "webhook" | "unknown";
82
+ sourceName: string | null;
83
+ sourceKind: WebhookType | null;
84
+ plugName: string | null;
85
+ startedAt: string;
86
+ }
87
+
88
+ interface RunPageResponse {
89
+ items?: RunRecord[];
90
+ }
91
+
92
+ interface TopologySnapshot {
93
+ plugs: PlugRecord[];
94
+ flows: FlowRecord[];
95
+ webhookHandlers: WebhookHandlerRecord[];
96
+ runs: RunRecord[];
97
+ }
98
+
99
+ interface FilterOption {
100
+ id: string;
101
+ label: string;
102
+ hint: string;
103
+ }
104
+
105
+ interface TopologyFilters {
106
+ plugIds: string[];
107
+ flowIds: string[];
108
+ webhookIds: string[];
109
+ healths: NodeHealth[];
110
+ }
111
+
112
+ interface GraphNodeModel {
113
+ id: string;
114
+ entityId: string;
115
+ category: NodeCategory;
116
+ lane: NodeLane;
117
+ label: string;
118
+ subtitle: string;
119
+ detail?: string;
120
+ health: NodeHealth;
121
+ muted?: boolean;
122
+ ownerPlugId?: string;
123
+ isVirtual?: boolean;
124
+ }
125
+
126
+ interface GraphEdgeModel {
127
+ id: string;
128
+ source: string;
129
+ target: string;
130
+ category: EdgeCategory;
131
+ health: NodeHealth;
132
+ label: string;
133
+ fallback?: boolean;
134
+ }
135
+
136
+ interface TopologyModel {
137
+ nodes: GraphNodeModel[];
138
+ edges: GraphEdgeModel[];
139
+ filters: {
140
+ plugs: FilterOption[];
141
+ flows: FilterOption[];
142
+ webhooks: FilterOption[];
143
+ };
144
+ }
145
+
146
+ interface FilteredTopologyGraph {
147
+ nodes: Array<Node<TopologyNodeData>>;
148
+ edges: Edge[];
149
+ stats: {
150
+ visibleNodes: number;
151
+ totalNodes: number;
152
+ visibleEdges: number;
153
+ activeNodes: number;
154
+ failedNodes: number;
155
+ };
156
+ hasAnyConfiguredTopology: boolean;
157
+ hasVisibleTopology: boolean;
158
+ }
159
+
160
+ interface TopologyNodeData {
161
+ category: NodeCategory;
162
+ label: string;
163
+ subtitle: string;
164
+ detail?: string;
165
+ health: NodeHealth;
166
+ muted?: boolean;
167
+ }
168
+
169
+ const POLL_INTERVAL_MS = 5000;
170
+ const LANE_X: Record<NodeLane, number> = {
171
+ plug: 60,
172
+ flow: 390,
173
+ database: 720,
174
+ webhook: 1040,
175
+ destination: 1360,
176
+ };
177
+ const NODE_SPACING_Y = 160;
178
+ const HEALTH_ORDER: NodeHealth[] = ["idle", "active", "failed"];
179
+ const HEALTH_LABEL: Record<NodeHealth, string> = {
180
+ idle: "Idle",
181
+ active: "Running",
182
+ failed: "Failed",
183
+ };
184
+ const CATEGORY_LABEL: Record<NodeCategory, string> = {
185
+ database: "Database",
186
+ plug: "Plug",
187
+ flow: "Flow",
188
+ webhook: "Webhook",
189
+ };
190
+ const FLOW_TYPE_LABEL: Record<FlowType, string> = {
191
+ inflow: "Inflow",
192
+ outflow: "Outflow",
193
+ relay: "Relay",
194
+ };
195
+ const WEBHOOK_TYPE_LABEL: Record<WebhookType, string> = {
196
+ catch: "Catch",
197
+ pass: "Pass",
198
+ };
199
+
200
+ function formatTime(value: string | null | undefined): string {
201
+ if (!value) return "Never";
202
+ const date = new Date(value);
203
+ if (Number.isNaN(date.getTime())) return value;
204
+ return date.toLocaleTimeString([], {
205
+ hour: "2-digit",
206
+ minute: "2-digit",
207
+ });
208
+ }
209
+
210
+ function normalizeRuns(value: unknown): RunRecord[] {
211
+ if (Array.isArray(value)) {
212
+ return value as RunRecord[];
213
+ }
214
+
215
+ if (
216
+ value &&
217
+ typeof value === "object" &&
218
+ Array.isArray((value as RunPageResponse).items)
219
+ ) {
220
+ return (value as RunPageResponse).items ?? [];
221
+ }
222
+
223
+ return [];
224
+ }
225
+
226
+ function sortByName<T extends { name: string }>(rows: T[]): T[] {
227
+ return [...rows].sort((a, b) => a.name.localeCompare(b.name));
228
+ }
229
+
230
+ function flowRunKey(flow: Pick<FlowRecord, "name" | "plugName">): string {
231
+ return `flow:${flow.plugName ?? "unknown"}:${flow.name}`;
232
+ }
233
+
234
+ function webhookRunKey(
235
+ handler: Pick<WebhookHandlerRecord, "name" | "type" | "plugName">,
236
+ ): string {
237
+ return `webhook:${handler.plugName}:${handler.type}:${handler.name}`;
238
+ }
239
+
240
+ function runKey(run: RunRecord): string | null {
241
+ if (run.sourceType === "flow" && run.sourceName && run.plugName) {
242
+ return `flow:${run.plugName}:${run.sourceName}`;
243
+ }
244
+
245
+ if (
246
+ run.sourceType === "webhook" &&
247
+ run.sourceName &&
248
+ run.sourceKind &&
249
+ run.plugName
250
+ ) {
251
+ return `webhook:${run.plugName}:${run.sourceKind}:${run.sourceName}`;
252
+ }
253
+
254
+ return null;
255
+ }
256
+
257
+ function buildLatestRunMap(runs: RunRecord[]): Map<string, RunRecord> {
258
+ const map = new Map<string, RunRecord>();
259
+
260
+ for (const run of runs) {
261
+ const key = runKey(run);
262
+ if (!key || map.has(key)) continue;
263
+ map.set(key, run);
264
+ }
265
+
266
+ return map;
267
+ }
268
+
269
+ function buildRunningRunSet(runs: RunRecord[]): Set<string> {
270
+ const set = new Set<string>();
271
+
272
+ for (const run of runs) {
273
+ const key = runKey(run);
274
+ if (!key || run.status !== "running") continue;
275
+ set.add(key);
276
+ }
277
+
278
+ return set;
279
+ }
280
+
281
+ function deriveHealth(
282
+ key: string,
283
+ latestRuns: Map<string, RunRecord>,
284
+ runningRuns: Set<string>,
285
+ fallbackStatus?: RunStatus,
286
+ ): NodeHealth {
287
+ if (runningRuns.has(key) || fallbackStatus === "running") {
288
+ return "active";
289
+ }
290
+
291
+ const latest = latestRuns.get(key);
292
+ if (latest?.status === "failed" || fallbackStatus === "failed") {
293
+ return "failed";
294
+ }
295
+
296
+ return "idle";
297
+ }
298
+
299
+ function resolvePlugNodeId(
300
+ plugsById: Map<string, PlugRecord>,
301
+ plugsByName: Map<string, PlugRecord>,
302
+ destinationPlugId: string | null | undefined,
303
+ fallbackName?: string | null,
304
+ ): string | null {
305
+ if (destinationPlugId && plugsById.has(destinationPlugId)) {
306
+ return `plug:${destinationPlugId}`;
307
+ }
308
+
309
+ if (fallbackName && plugsByName.has(fallbackName)) {
310
+ return `plug:${plugsByName.get(fallbackName)!.id}`;
311
+ }
312
+
313
+ return null;
314
+ }
315
+
316
+ function edgeLabel(category: EdgeCategory): string {
317
+ switch (category) {
318
+ case "inflow":
319
+ return "pull";
320
+ case "outflow":
321
+ return "push";
322
+ case "relay":
323
+ return "relay";
324
+ case "catch":
325
+ return "catch";
326
+ case "pass":
327
+ return "pass";
328
+ }
329
+ }
330
+
331
+ function buildTopologyModel(snapshot: TopologySnapshot): TopologyModel {
332
+ const latestRuns = buildLatestRunMap(snapshot.runs);
333
+ const runningRuns = buildRunningRunSet(snapshot.runs);
334
+ const plugs = sortByName(snapshot.plugs);
335
+ const flows = [...snapshot.flows].sort((a, b) =>
336
+ `${a.plugName ?? ""}:${a.name}`.localeCompare(
337
+ `${b.plugName ?? ""}:${b.name}`,
338
+ ),
339
+ );
340
+ const webhookHandlers = [...snapshot.webhookHandlers].sort((a, b) =>
341
+ `${a.plugName}:${a.type}:${a.name}`.localeCompare(
342
+ `${b.plugName}:${b.type}:${b.name}`,
343
+ ),
344
+ );
345
+
346
+ const plugsById = new Map(plugs.map((plug) => [plug.id, plug]));
347
+ const plugsByName = new Map(plugs.map((plug) => [plug.name, plug]));
348
+ const nodes: GraphNodeModel[] = [
349
+ {
350
+ id: "database:primary",
351
+ entityId: "database:primary",
352
+ category: "database",
353
+ lane: "database",
354
+ label: "App Database",
355
+ subtitle: "Shared storage for synced resources",
356
+ detail: "khotan resources + mappings",
357
+ health: "idle",
358
+ },
359
+ ];
360
+ const edges: GraphEdgeModel[] = [];
361
+
362
+ for (const plug of plugs) {
363
+ nodes.push({
364
+ id: `plug:${plug.id}`,
365
+ entityId: plug.id,
366
+ category: "plug",
367
+ lane: "plug",
368
+ label: plug.name,
369
+ subtitle: plug.baseUrl,
370
+ detail: plug.enabled ? plug.authType : "disabled",
371
+ health: "idle",
372
+ muted: !plug.enabled,
373
+ ownerPlugId: plug.id,
374
+ });
375
+ }
376
+
377
+ for (const flow of flows) {
378
+ const health = deriveHealth(
379
+ flowRunKey(flow),
380
+ latestRuns,
381
+ runningRuns,
382
+ flow.lastRunStatus,
383
+ );
384
+
385
+ nodes.push({
386
+ id: `flow:${flow.id}`,
387
+ entityId: flow.id,
388
+ category: "flow",
389
+ lane: "flow",
390
+ label: flow.name,
391
+ subtitle: FLOW_TYPE_LABEL[flow.type],
392
+ detail: flow.schedule ?? flow.lastRunStatus ?? "manual trigger",
393
+ health,
394
+ muted: !flow.enabled,
395
+ ownerPlugId: flow.plugId,
396
+ });
397
+
398
+ if (flow.type === "inflow") {
399
+ edges.push({
400
+ id: `edge:plug-flow:${flow.id}`,
401
+ source: `plug:${flow.plugId}`,
402
+ target: `flow:${flow.id}`,
403
+ category: "inflow",
404
+ health,
405
+ label: edgeLabel("inflow"),
406
+ });
407
+ edges.push({
408
+ id: `edge:flow-db:${flow.id}`,
409
+ source: `flow:${flow.id}`,
410
+ target: "database:primary",
411
+ category: "inflow",
412
+ health,
413
+ label: edgeLabel("inflow"),
414
+ });
415
+ continue;
416
+ }
417
+
418
+ if (flow.type === "outflow") {
419
+ edges.push({
420
+ id: `edge:db-flow:${flow.id}`,
421
+ source: "database:primary",
422
+ target: `flow:${flow.id}`,
423
+ category: "outflow",
424
+ health,
425
+ label: edgeLabel("outflow"),
426
+ });
427
+ edges.push({
428
+ id: `edge:flow-plug:${flow.id}`,
429
+ source: `flow:${flow.id}`,
430
+ target: `plug:${flow.plugId}`,
431
+ category: "outflow",
432
+ health,
433
+ label: edgeLabel("outflow"),
434
+ });
435
+ continue;
436
+ }
437
+
438
+ edges.push({
439
+ id: `edge:plug-flow:${flow.id}`,
440
+ source: `plug:${flow.plugId}`,
441
+ target: `flow:${flow.id}`,
442
+ category: "relay",
443
+ health,
444
+ label: edgeLabel("relay"),
445
+ });
446
+
447
+ const relayTargetId = resolvePlugNodeId(
448
+ plugsById,
449
+ plugsByName,
450
+ null,
451
+ flow.to ?? null,
452
+ );
453
+
454
+ if (relayTargetId) {
455
+ edges.push({
456
+ id: `edge:relay-destination:${flow.id}`,
457
+ source: `flow:${flow.id}`,
458
+ target: relayTargetId,
459
+ category: "relay",
460
+ health,
461
+ label: edgeLabel("relay"),
462
+ });
463
+ continue;
464
+ }
465
+
466
+ const fallbackRelayNodeId = `plug:virtual:relay:${flow.id}`;
467
+ nodes.push({
468
+ id: fallbackRelayNodeId,
469
+ entityId: fallbackRelayNodeId,
470
+ category: "plug",
471
+ lane: "destination",
472
+ label: "Destination unavailable",
473
+ subtitle: "Relay target is not exposed by the current API payload",
474
+ detail: flow.to ?? "scaffolded relay destination",
475
+ health: "idle",
476
+ muted: true,
477
+ ownerPlugId: flow.plugId,
478
+ isVirtual: true,
479
+ });
480
+ edges.push({
481
+ id: `edge:relay-fallback:${flow.id}`,
482
+ source: `flow:${flow.id}`,
483
+ target: fallbackRelayNodeId,
484
+ category: "relay",
485
+ health,
486
+ label: "relay target",
487
+ fallback: true,
488
+ });
489
+ }
490
+
491
+ for (const handler of webhookHandlers) {
492
+ const sourcePlug = plugsByName.get(handler.plugName);
493
+ if (!sourcePlug) continue;
494
+
495
+ const health = deriveHealth(
496
+ webhookRunKey(handler),
497
+ latestRuns,
498
+ runningRuns,
499
+ handler.lastRunStatus,
500
+ );
501
+
502
+ nodes.push({
503
+ id: `webhook:${handler.id}`,
504
+ entityId: handler.id,
505
+ category: "webhook",
506
+ lane: "webhook",
507
+ label: handler.name,
508
+ subtitle: WEBHOOK_TYPE_LABEL[handler.type],
509
+ detail: handler.events?.length
510
+ ? handler.events.join(", ")
511
+ : "wire events",
512
+ health,
513
+ muted: !handler.enabled,
514
+ ownerPlugId: sourcePlug.id,
515
+ });
516
+
517
+ edges.push({
518
+ id: `edge:plug-webhook:${handler.id}`,
519
+ source: `plug:${sourcePlug.id}`,
520
+ target: `webhook:${handler.id}`,
521
+ category: handler.type,
522
+ health,
523
+ label: edgeLabel(handler.type),
524
+ });
525
+
526
+ if (handler.type === "catch") {
527
+ edges.push({
528
+ id: `edge:webhook-db:${handler.id}`,
529
+ source: `webhook:${handler.id}`,
530
+ target: "database:primary",
531
+ category: "catch",
532
+ health,
533
+ label: edgeLabel("catch"),
534
+ });
535
+ continue;
536
+ }
537
+
538
+ const destinationNodeId = resolvePlugNodeId(
539
+ plugsById,
540
+ plugsByName,
541
+ handler.destinationPlugId,
542
+ null,
543
+ );
544
+
545
+ if (destinationNodeId) {
546
+ edges.push({
547
+ id: `edge:pass-destination:${handler.id}`,
548
+ source: `webhook:${handler.id}`,
549
+ target: destinationNodeId,
550
+ category: "pass",
551
+ health,
552
+ label: edgeLabel("pass"),
553
+ });
554
+ continue;
555
+ }
556
+
557
+ const fallbackPassNodeId = `plug:virtual:pass:${handler.id}`;
558
+ nodes.push({
559
+ id: fallbackPassNodeId,
560
+ entityId: fallbackPassNodeId,
561
+ category: "plug",
562
+ lane: "destination",
563
+ label: "Destination unavailable",
564
+ subtitle:
565
+ "Pass target could not be resolved from the current plug registry",
566
+ detail: handler.destinationPlugId ?? "configure a destination plug",
567
+ health: "idle",
568
+ muted: true,
569
+ ownerPlugId: sourcePlug.id,
570
+ isVirtual: true,
571
+ });
572
+ edges.push({
573
+ id: `edge:pass-fallback:${handler.id}`,
574
+ source: `webhook:${handler.id}`,
575
+ target: fallbackPassNodeId,
576
+ category: "pass",
577
+ health,
578
+ label: "pass target",
579
+ fallback: true,
580
+ });
581
+ }
582
+
583
+ return {
584
+ nodes,
585
+ edges,
586
+ filters: {
587
+ plugs: plugs.map((plug) => ({
588
+ id: plug.id,
589
+ label: plug.name,
590
+ hint: `${plug.authType} ${plug.enabled ? "" : "• disabled"}`.trim(),
591
+ })),
592
+ flows: flows.map((flow) => ({
593
+ id: flow.id,
594
+ label: flow.name,
595
+ hint: `${flow.plugName ?? "Unknown"} • ${FLOW_TYPE_LABEL[flow.type]}`,
596
+ })),
597
+ webhooks: webhookHandlers.map((handler) => ({
598
+ id: handler.id,
599
+ label: handler.name,
600
+ hint: `${handler.plugName} • ${WEBHOOK_TYPE_LABEL[handler.type]}`,
601
+ })),
602
+ },
603
+ };
604
+ }
605
+
606
+ function arraysEqual(left: string[], right: string[]): boolean {
607
+ return (
608
+ left.length === right.length &&
609
+ left.every((value, index) => value === right[index])
610
+ );
611
+ }
612
+
613
+ function createDefaultFilters(model: TopologyModel): TopologyFilters {
614
+ return {
615
+ plugIds: model.filters.plugs.map((item) => item.id),
616
+ flowIds: model.filters.flows.map((item) => item.id),
617
+ webhookIds: model.filters.webhooks.map((item) => item.id),
618
+ healths: [...HEALTH_ORDER],
619
+ };
620
+ }
621
+
622
+ function reconcileFilters(
623
+ current: TopologyFilters | null,
624
+ model: TopologyModel,
625
+ ): TopologyFilters {
626
+ const defaults = createDefaultFilters(model);
627
+ if (!current) return defaults;
628
+
629
+ const next: TopologyFilters = {
630
+ plugIds: current.plugIds.filter((id) => defaults.plugIds.includes(id)),
631
+ flowIds: current.flowIds.filter((id) => defaults.flowIds.includes(id)),
632
+ webhookIds: current.webhookIds.filter((id) =>
633
+ defaults.webhookIds.includes(id),
634
+ ),
635
+ healths: current.healths.filter((value) =>
636
+ defaults.healths.includes(value),
637
+ ),
638
+ };
639
+
640
+ if (next.plugIds.length === 0 && defaults.plugIds.length > 0) {
641
+ next.plugIds = defaults.plugIds;
642
+ }
643
+ if (next.flowIds.length === 0 && defaults.flowIds.length > 0) {
644
+ next.flowIds = defaults.flowIds;
645
+ }
646
+ if (next.webhookIds.length === 0 && defaults.webhookIds.length > 0) {
647
+ next.webhookIds = defaults.webhookIds;
648
+ }
649
+ if (next.healths.length === 0) {
650
+ next.healths = defaults.healths;
651
+ }
652
+
653
+ if (
654
+ arraysEqual(next.plugIds, current.plugIds) &&
655
+ arraysEqual(next.flowIds, current.flowIds) &&
656
+ arraysEqual(next.webhookIds, current.webhookIds) &&
657
+ arraysEqual(next.healths, current.healths)
658
+ ) {
659
+ return current;
660
+ }
661
+
662
+ return next;
663
+ }
664
+
665
+ function layoutNodes(nodes: GraphNodeModel[]): Array<Node<TopologyNodeData>> {
666
+ const laneIndexes: Record<NodeLane, number> = {
667
+ plug: 0,
668
+ flow: 0,
669
+ database: 0,
670
+ webhook: 0,
671
+ destination: 0,
672
+ };
673
+
674
+ return nodes.map((node) => {
675
+ const laneIndex = laneIndexes[node.lane]++;
676
+ const yBase = node.lane === "database" ? 170 : 36;
677
+ return {
678
+ id: node.id,
679
+ type: "topology",
680
+ position: {
681
+ x: LANE_X[node.lane],
682
+ y: yBase + laneIndex * NODE_SPACING_Y,
683
+ },
684
+ draggable: true,
685
+ data: {
686
+ category: node.category,
687
+ label: node.label,
688
+ subtitle: node.subtitle,
689
+ detail: node.detail,
690
+ health: node.health,
691
+ muted: node.muted,
692
+ },
693
+ };
694
+ });
695
+ }
696
+
697
+ function reconcileNodes(
698
+ current: Array<Node<TopologyNodeData>>,
699
+ next: Array<Node<TopologyNodeData>>,
700
+ ): Array<Node<TopologyNodeData>> {
701
+ const currentById = new Map(current.map((node) => [node.id, node]));
702
+
703
+ return next.map((node) => {
704
+ const existing = currentById.get(node.id);
705
+ if (!existing) return node;
706
+ return {
707
+ ...node,
708
+ position: existing.position,
709
+ };
710
+ });
711
+ }
712
+
713
+ function buildEdgeStyle(edge: GraphEdgeModel): CSSProperties {
714
+ return {
715
+ strokeWidth:
716
+ edge.health === "failed" ? 2.7 : edge.health === "active" ? 2.35 : 1.7,
717
+ stroke:
718
+ edge.health === "failed"
719
+ ? "#ef4444"
720
+ : edge.health === "active"
721
+ ? "#f59e0b"
722
+ : edge.fallback
723
+ ? "#94a3b8"
724
+ : "#475569",
725
+ opacity: edge.fallback ? 0.72 : 0.96,
726
+ strokeDasharray: edge.fallback ? "5 4" : undefined,
727
+ };
728
+ }
729
+
730
+ function buildReactFlowGraph(
731
+ model: TopologyModel,
732
+ filters: TopologyFilters,
733
+ ): FilteredTopologyGraph {
734
+ const selectedPlugIds = new Set(filters.plugIds);
735
+ const selectedFlowIds = new Set(filters.flowIds);
736
+ const selectedWebhookIds = new Set(filters.webhookIds);
737
+ const selectedHealths = new Set(filters.healths);
738
+
739
+ const visibleNodeIds = new Set<string>();
740
+ const nodesById = new Map(model.nodes.map((node) => [node.id, node]));
741
+
742
+ for (const node of model.nodes) {
743
+ if (node.category === "flow") {
744
+ if (
745
+ selectedFlowIds.has(node.entityId) &&
746
+ selectedHealths.has(node.health) &&
747
+ (!node.ownerPlugId || selectedPlugIds.has(node.ownerPlugId))
748
+ ) {
749
+ visibleNodeIds.add(node.id);
750
+ }
751
+ continue;
752
+ }
753
+
754
+ if (node.category === "webhook") {
755
+ if (
756
+ selectedWebhookIds.has(node.entityId) &&
757
+ selectedHealths.has(node.health) &&
758
+ (!node.ownerPlugId || selectedPlugIds.has(node.ownerPlugId))
759
+ ) {
760
+ visibleNodeIds.add(node.id);
761
+ }
762
+ continue;
763
+ }
764
+
765
+ if (
766
+ node.category === "plug" &&
767
+ !node.isVirtual &&
768
+ selectedPlugIds.has(node.entityId)
769
+ ) {
770
+ visibleNodeIds.add(node.id);
771
+ }
772
+ }
773
+
774
+ for (const edge of model.edges) {
775
+ const sourceVisible = visibleNodeIds.has(edge.source);
776
+ const targetVisible = visibleNodeIds.has(edge.target);
777
+ if (sourceVisible === targetVisible) continue;
778
+
779
+ const counterpartId = sourceVisible ? edge.target : edge.source;
780
+ const counterpart = nodesById.get(counterpartId);
781
+ if (!counterpart) continue;
782
+
783
+ if (
784
+ counterpart.category === "plug" ||
785
+ counterpart.category === "database"
786
+ ) {
787
+ visibleNodeIds.add(counterpart.id);
788
+ }
789
+ }
790
+
791
+ const visibleEdges = model.edges.filter((edge) => {
792
+ return visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target);
793
+ });
794
+
795
+ for (const edge of visibleEdges) {
796
+ visibleNodeIds.add(edge.source);
797
+ visibleNodeIds.add(edge.target);
798
+ }
799
+
800
+ const visibleNodes = model.nodes.filter((node) =>
801
+ visibleNodeIds.has(node.id),
802
+ );
803
+ const laidOutNodes = layoutNodes(visibleNodes);
804
+ const edges: Edge[] = visibleEdges.map((edge) => {
805
+ const style = buildEdgeStyle(edge);
806
+ return {
807
+ id: edge.id,
808
+ source: edge.source,
809
+ target: edge.target,
810
+ label: edge.label,
811
+ type: "smoothstep",
812
+ animated: edge.health === "active",
813
+ style,
814
+ markerEnd: {
815
+ type: MarkerType.ArrowClosed,
816
+ color: style.stroke,
817
+ },
818
+ labelStyle: {
819
+ fontSize: 10,
820
+ fill: style.stroke as string,
821
+ },
822
+ labelBgStyle: {
823
+ fill: "#ffffff",
824
+ fillOpacity: 0.92,
825
+ },
826
+ labelBgPadding: [6, 2],
827
+ labelBgBorderRadius: 999,
828
+ };
829
+ });
830
+
831
+ return {
832
+ nodes: laidOutNodes,
833
+ edges,
834
+ stats: {
835
+ visibleNodes: visibleNodes.length,
836
+ totalNodes: model.nodes.filter((node) => !node.isVirtual).length,
837
+ visibleEdges: visibleEdges.length,
838
+ activeNodes: visibleNodes.filter((node) => node.health === "active")
839
+ .length,
840
+ failedNodes: visibleNodes.filter((node) => node.health === "failed")
841
+ .length,
842
+ },
843
+ hasAnyConfiguredTopology:
844
+ model.filters.plugs.length > 0 &&
845
+ (model.filters.flows.length > 0 || model.filters.webhooks.length > 0),
846
+ hasVisibleTopology: visibleNodes.length > 0 && visibleEdges.length > 0,
847
+ };
848
+ }
849
+
850
+ function toggleValue(values: string[], value: string): string[] {
851
+ return values.includes(value)
852
+ ? values.filter((item) => item !== value)
853
+ : [...values, value];
854
+ }
855
+
856
+ function toggleHealth(values: NodeHealth[], value: NodeHealth): NodeHealth[] {
857
+ return values.includes(value)
858
+ ? values.filter((item) => item !== value)
859
+ : [...values, value];
860
+ }
861
+
862
+ function summaryLabel(label: string, selected: number, total: number): string {
863
+ if (total === 0) return label;
864
+ if (selected === total) return `${label} (${total})`;
865
+ return `${label} (${selected}/${total})`;
866
+ }
867
+
868
+ function FilterDropdown({
869
+ label,
870
+ options,
871
+ selected,
872
+ onToggle,
873
+ }: {
874
+ label: string;
875
+ options: FilterOption[];
876
+ selected: string[];
877
+ onToggle(id: string): void;
878
+ }) {
879
+ if (options.length === 0) return null;
880
+
881
+ return (
882
+ <details className="group relative">
883
+ <summary className="list-none rounded-full border border-slate-200 bg-white/90 px-3 py-1.5 text-xs font-medium text-slate-700 shadow-sm transition hover:border-slate-300">
884
+ {summaryLabel(label, selected.length, options.length)}
885
+ </summary>
886
+
887
+ <div className="absolute left-0 z-10 mt-2 w-[280px] rounded-2xl border border-slate-200 bg-white p-3 shadow-xl">
888
+ <div className="mb-2 text-[11px] font-medium uppercase tracking-[0.16em] text-slate-500">
889
+ {label}
890
+ </div>
891
+ <div className="max-h-72 space-y-2 overflow-auto pr-1">
892
+ {options.map((option) => {
893
+ const checked = selected.includes(option.id);
894
+ return (
895
+ <label
896
+ key={option.id}
897
+ className="flex cursor-pointer items-start gap-2 rounded-xl px-2 py-1.5 hover:bg-slate-50"
898
+ >
899
+ <input
900
+ type="checkbox"
901
+ checked={checked}
902
+ onChange={() => onToggle(option.id)}
903
+ className="mt-0.5 h-4 w-4 rounded border-slate-300 text-slate-900"
904
+ />
905
+ <span className="min-w-0">
906
+ <span className="block text-xs font-medium text-slate-800">
907
+ {option.label}
908
+ </span>
909
+ <span className="block text-[11px] text-slate-500">
910
+ {option.hint}
911
+ </span>
912
+ </span>
913
+ </label>
914
+ );
915
+ })}
916
+ </div>
917
+ </div>
918
+ </details>
919
+ );
920
+ }
921
+
922
+ function StatusDropdown({
923
+ selected,
924
+ onToggle,
925
+ }: {
926
+ selected: NodeHealth[];
927
+ onToggle(value: NodeHealth): void;
928
+ }) {
929
+ return (
930
+ <details className="group relative">
931
+ <summary className="list-none rounded-full border border-slate-200 bg-white/90 px-3 py-1.5 text-xs font-medium text-slate-700 shadow-sm transition hover:border-slate-300">
932
+ {summaryLabel("Status", selected.length, HEALTH_ORDER.length)}
933
+ </summary>
934
+
935
+ <div className="absolute left-0 z-10 mt-2 w-48 rounded-2xl border border-slate-200 bg-white p-3 shadow-xl">
936
+ <div className="mb-2 text-[11px] font-medium uppercase tracking-[0.16em] text-slate-500">
937
+ Status
938
+ </div>
939
+ <div className="space-y-2">
940
+ {HEALTH_ORDER.map((health) => (
941
+ <label
942
+ key={health}
943
+ className="flex cursor-pointer items-center gap-2 rounded-xl px-2 py-1.5 hover:bg-slate-50"
944
+ >
945
+ <input
946
+ type="checkbox"
947
+ checked={selected.includes(health)}
948
+ onChange={() => onToggle(health)}
949
+ className="h-4 w-4 rounded border-slate-300 text-slate-900"
950
+ />
951
+ <span className="flex items-center gap-2 text-xs text-slate-700">
952
+ <span
953
+ className={`h-2 w-2 rounded-full ${
954
+ health === "failed"
955
+ ? "bg-red-500"
956
+ : health === "active"
957
+ ? "bg-amber-500"
958
+ : "bg-slate-400"
959
+ }`}
960
+ />
961
+ {HEALTH_LABEL[health]}
962
+ </span>
963
+ </label>
964
+ ))}
965
+ </div>
966
+ </div>
967
+ </details>
968
+ );
969
+ }
970
+
971
+ function TopologyNode({ data }: NodeProps<TopologyNodeData>) {
972
+ const healthBorder =
973
+ data.health === "failed"
974
+ ? "border-red-300 shadow-red-100"
975
+ : data.health === "active"
976
+ ? "border-amber-300 shadow-amber-100"
977
+ : "border-white/80 shadow-slate-200/70";
978
+ return (
979
+ <div
980
+ className={`min-w-[220px] rounded-3xl border bg-white/88 p-4 shadow-lg backdrop-blur-xl ${healthBorder}`}
981
+ >
982
+ <Handle
983
+ type="target"
984
+ position={Position.Left}
985
+ className="!h-2.5 !w-2.5 !border-0 !bg-slate-400"
986
+ />
987
+ <Handle
988
+ type="source"
989
+ position={Position.Right}
990
+ className="!h-2.5 !w-2.5 !border-0 !bg-slate-500"
991
+ />
992
+
993
+ <div className="flex items-start justify-between gap-3">
994
+ <div className="space-y-1">
995
+ <div className="text-sm font-semibold text-slate-950">
996
+ {data.label}
997
+ </div>
998
+ <div className="text-xs text-slate-500">{data.subtitle}</div>
999
+ </div>
1000
+ <div
1001
+ className={`mt-1 h-2.5 w-2.5 rounded-full ${
1002
+ data.health === "failed"
1003
+ ? "bg-red-500"
1004
+ : data.health === "active"
1005
+ ? "bg-amber-500"
1006
+ : "bg-slate-400"
1007
+ }`}
1008
+ />
1009
+ </div>
1010
+
1011
+ <div className="mt-4 flex flex-wrap gap-2">
1012
+ <Badge
1013
+ variant="outline"
1014
+ className="border-slate-200 bg-white/70 text-slate-600"
1015
+ >
1016
+ {CATEGORY_LABEL[data.category]}
1017
+ </Badge>
1018
+ <Badge
1019
+ variant="outline"
1020
+ className={
1021
+ data.health === "failed"
1022
+ ? "border-red-200 bg-red-50 text-red-700"
1023
+ : data.health === "active"
1024
+ ? "border-amber-200 bg-amber-50 text-amber-700"
1025
+ : "border-slate-200 bg-slate-50 text-slate-600"
1026
+ }
1027
+ >
1028
+ {HEALTH_LABEL[data.health]}
1029
+ </Badge>
1030
+ {data.muted ? (
1031
+ <Badge
1032
+ variant="outline"
1033
+ className="border-slate-200 bg-white/70 text-slate-500"
1034
+ >
1035
+ Disabled
1036
+ </Badge>
1037
+ ) : null}
1038
+ </div>
1039
+
1040
+ {data.detail ? (
1041
+ <div className="mt-4 border-t border-slate-100 pt-4 text-xs leading-5 text-slate-500">
1042
+ {data.detail}
1043
+ </div>
1044
+ ) : null}
1045
+ </div>
1046
+ );
1047
+ }
1048
+
1049
+ const nodeTypes = {
1050
+ topology: TopologyNode,
1051
+ };
1052
+
1053
+ function TopologyCanvasInner() {
1054
+ const [snapshot, setSnapshot] = useState<TopologySnapshot | null>(null);
1055
+ const [filters, setFilters] = useState<TopologyFilters | null>(null);
1056
+ const [nodes, setNodes, onNodesChange] = useNodesState<TopologyNodeData>([]);
1057
+ const [edges, setEdges, onEdgesChange] = useEdgesState([]);
1058
+ const [loading, setLoading] = useState(true);
1059
+ const [error, setError] = useState<string | null>(null);
1060
+ const [lastUpdatedAt, setLastUpdatedAt] = useState<string | null>(null);
1061
+
1062
+ useEffect(() => {
1063
+ let cancelled = false;
1064
+
1065
+ async function loadTopology() {
1066
+ try {
1067
+ if (!cancelled) {
1068
+ setError(null);
1069
+ }
1070
+
1071
+ const [plugsRes, flowsRes, runsRes] = await Promise.all([
1072
+ fetch("/api/khotan/plugs"),
1073
+ fetch("/api/khotan/flows"),
1074
+ fetch("/api/khotan/runs?limit=100"),
1075
+ ]);
1076
+
1077
+ if (!plugsRes.ok || !flowsRes.ok || !runsRes.ok) {
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());
1084
+
1085
+ const webhookGroups = await Promise.all(
1086
+ plugs.map(async (plug) => {
1087
+ try {
1088
+ const res = await fetch(
1089
+ `/api/khotan/webhook-handlers/${encodeURIComponent(plug.name)}`,
1090
+ );
1091
+ if (!res.ok) return [] as WebhookHandlerRecord[];
1092
+ const handlers = (await res.json()) as Array<
1093
+ Omit<WebhookHandlerRecord, "plugName">
1094
+ >;
1095
+ return handlers.map((handler) => ({
1096
+ ...handler,
1097
+ plugName: plug.name,
1098
+ }));
1099
+ } catch {
1100
+ return [] as WebhookHandlerRecord[];
1101
+ }
1102
+ }),
1103
+ );
1104
+
1105
+ if (cancelled) return;
1106
+
1107
+ setSnapshot({
1108
+ plugs,
1109
+ flows,
1110
+ webhookHandlers: webhookGroups.flat(),
1111
+ runs,
1112
+ });
1113
+ setLastUpdatedAt(new Date().toISOString());
1114
+ } catch (loadError) {
1115
+ if (!cancelled) {
1116
+ setError(
1117
+ loadError instanceof Error
1118
+ ? loadError.message
1119
+ : "Unknown topology load failure",
1120
+ );
1121
+ }
1122
+ } finally {
1123
+ if (!cancelled) {
1124
+ setLoading(false);
1125
+ }
1126
+ }
1127
+ }
1128
+
1129
+ void loadTopology();
1130
+ const interval = window.setInterval(() => {
1131
+ void loadTopology();
1132
+ }, POLL_INTERVAL_MS);
1133
+
1134
+ return () => {
1135
+ cancelled = true;
1136
+ window.clearInterval(interval);
1137
+ };
1138
+ }, []);
1139
+
1140
+ const model = useMemo(() => {
1141
+ return snapshot ? buildTopologyModel(snapshot) : null;
1142
+ }, [snapshot]);
1143
+
1144
+ useEffect(() => {
1145
+ if (!model) return;
1146
+ setFilters((current) => reconcileFilters(current, model));
1147
+ }, [model]);
1148
+
1149
+ const graph = useMemo(() => {
1150
+ if (!model || !filters) return null;
1151
+ return buildReactFlowGraph(model, filters);
1152
+ }, [filters, model]);
1153
+
1154
+ useEffect(() => {
1155
+ if (!graph) return;
1156
+ setNodes((current) => reconcileNodes(current, graph.nodes));
1157
+ setEdges(graph.edges);
1158
+ }, [graph, setEdges, setNodes]);
1159
+
1160
+ const resetFilters = () => {
1161
+ if (!model) return;
1162
+ setFilters(createDefaultFilters(model));
1163
+ };
1164
+
1165
+ if (loading) {
1166
+ return (
1167
+ <Card className="overflow-hidden border-white/70 bg-white/75 shadow-xl backdrop-blur">
1168
+ <CardHeader>
1169
+ <CardTitle>Topology Canvas</CardTitle>
1170
+ <CardDescription>
1171
+ Loading plugs, flows, webhook handlers, and recent runs.
1172
+ </CardDescription>
1173
+ </CardHeader>
1174
+ <CardContent>
1175
+ <div className="h-[720px] animate-pulse rounded-[28px] border border-dashed border-slate-200 bg-[radial-gradient(circle_at_top,_rgba(255,255,255,0.96),_rgba(241,245,249,0.76))]" />
1176
+ </CardContent>
1177
+ </Card>
1178
+ );
1179
+ }
1180
+
1181
+ if (error) {
1182
+ return (
1183
+ <Card className="border-red-200 bg-white/80 shadow-xl backdrop-blur">
1184
+ <CardHeader>
1185
+ <CardTitle>Topology Canvas</CardTitle>
1186
+ <CardDescription>
1187
+ Could not load the graph from the local Khotan API.
1188
+ </CardDescription>
1189
+ </CardHeader>
1190
+ <CardContent className="space-y-3 text-sm">
1191
+ <p className="text-red-600">{error}</p>
1192
+ <p className="text-slate-500">
1193
+ Make sure the catch-all Khotan route is mounted and your dev server
1194
+ is running.
1195
+ </p>
1196
+ </CardContent>
1197
+ </Card>
1198
+ );
1199
+ }
1200
+
1201
+ if (!model || !graph) {
1202
+ return null;
1203
+ }
1204
+
1205
+ if (!graph.hasAnyConfiguredTopology) {
1206
+ return (
1207
+ <Card className="overflow-hidden border-white/70 bg-white/80 shadow-xl backdrop-blur">
1208
+ <CardHeader>
1209
+ <CardTitle>Topology Canvas</CardTitle>
1210
+ <CardDescription>
1211
+ No graphable plugs, flows, or webhook handlers are configured yet.
1212
+ </CardDescription>
1213
+ </CardHeader>
1214
+ <CardContent className="space-y-3 text-sm text-slate-600">
1215
+ <p>
1216
+ Register at least one plug and one flow in your `khotan.ts` config,
1217
+ then refresh this page.
1218
+ </p>
1219
+ <p>
1220
+ Webhook handlers will appear automatically once a plug has a
1221
+ configured wire plus catch/pass registrations.
1222
+ </p>
1223
+ </CardContent>
1224
+ </Card>
1225
+ );
1226
+ }
1227
+
1228
+ return (
1229
+ <Card className="overflow-hidden border-white/70 bg-white/65 shadow-2xl backdrop-blur-xl">
1230
+ <CardHeader className="border-b border-white/70 bg-[linear-gradient(180deg,rgba(255,255,255,0.9),rgba(248,250,252,0.74))] pb-4">
1231
+ <div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
1232
+ <div className="flex flex-wrap items-center gap-2 text-xs">
1233
+ <Badge
1234
+ variant="outline"
1235
+ className="border-slate-200 bg-white/90 text-slate-600"
1236
+ >
1237
+ Polling every {POLL_INTERVAL_MS / 1000}s
1238
+ </Badge>
1239
+ <Badge
1240
+ variant="outline"
1241
+ className="border-slate-200 bg-white/90 text-slate-600"
1242
+ >
1243
+ Visible {graph.stats.visibleNodes}/{graph.stats.totalNodes}
1244
+ </Badge>
1245
+ <Badge
1246
+ variant="outline"
1247
+ className="border-amber-200 bg-amber-50 text-amber-700"
1248
+ >
1249
+ Running {graph.stats.activeNodes}
1250
+ </Badge>
1251
+ <Badge
1252
+ variant="outline"
1253
+ className="border-red-200 bg-red-50 text-red-700"
1254
+ >
1255
+ Failed {graph.stats.failedNodes}
1256
+ </Badge>
1257
+ <Badge
1258
+ variant="outline"
1259
+ className="border-slate-200 bg-white/90 text-slate-600"
1260
+ >
1261
+ Updated {formatTime(lastUpdatedAt)}
1262
+ </Badge>
1263
+ </div>
1264
+
1265
+ <div className="flex flex-wrap items-center gap-2">
1266
+ <FilterDropdown
1267
+ label="Plugs"
1268
+ options={model.filters.plugs}
1269
+ selected={filters.plugIds}
1270
+ onToggle={(id) =>
1271
+ setFilters((current) => {
1272
+ if (!current) return current;
1273
+ return {
1274
+ ...current,
1275
+ plugIds: toggleValue(current.plugIds, id),
1276
+ };
1277
+ })
1278
+ }
1279
+ />
1280
+ <FilterDropdown
1281
+ label="Flows"
1282
+ options={model.filters.flows}
1283
+ selected={filters.flowIds}
1284
+ onToggle={(id) =>
1285
+ setFilters((current) => {
1286
+ if (!current) return current;
1287
+ return {
1288
+ ...current,
1289
+ flowIds: toggleValue(current.flowIds, id),
1290
+ };
1291
+ })
1292
+ }
1293
+ />
1294
+ <FilterDropdown
1295
+ label="Webhook handlers"
1296
+ options={model.filters.webhooks}
1297
+ selected={filters.webhookIds}
1298
+ onToggle={(id) =>
1299
+ setFilters((current) => {
1300
+ if (!current) return current;
1301
+ return {
1302
+ ...current,
1303
+ webhookIds: toggleValue(current.webhookIds, id),
1304
+ };
1305
+ })
1306
+ }
1307
+ />
1308
+ <StatusDropdown
1309
+ selected={filters.healths}
1310
+ onToggle={(health) =>
1311
+ setFilters((current) => {
1312
+ if (!current) return current;
1313
+ return {
1314
+ ...current,
1315
+ healths: toggleHealth(current.healths, health),
1316
+ };
1317
+ })
1318
+ }
1319
+ />
1320
+ <button
1321
+ type="button"
1322
+ onClick={resetFilters}
1323
+ className="rounded-full border border-slate-200 bg-white/90 px-3 py-1.5 text-xs font-medium text-slate-600 shadow-sm transition hover:border-slate-300 hover:text-slate-900"
1324
+ >
1325
+ Reset
1326
+ </button>
1327
+ </div>
1328
+ </div>
1329
+ </CardHeader>
1330
+
1331
+ <CardContent className="p-0">
1332
+ <div className="h-[780px] bg-[radial-gradient(circle_at_top,_rgba(255,255,255,0.98),_rgba(241,245,249,0.88)_40%,_rgba(226,232,240,0.68)_100%)]">
1333
+ {graph.hasVisibleTopology ? (
1334
+ <ReactFlow
1335
+ fitView
1336
+ fitViewOptions={{ padding: 0.14 }}
1337
+ minZoom={0.35}
1338
+ maxZoom={1.7}
1339
+ nodes={nodes}
1340
+ edges={edges}
1341
+ nodeTypes={nodeTypes}
1342
+ onNodesChange={onNodesChange}
1343
+ onEdgesChange={onEdgesChange}
1344
+ proOptions={{ hideAttribution: true }}
1345
+ >
1346
+ <Background gap={20} size={1.1} color="#cbd5e1" />
1347
+ <MiniMap
1348
+ position="bottom-left"
1349
+ pannable
1350
+ zoomable
1351
+ style={{
1352
+ background: "rgba(255,255,255,0.8)",
1353
+ border: "1px solid rgba(226,232,240,0.9)",
1354
+ }}
1355
+ nodeColor={(node) => {
1356
+ const nodeHealth = (node.data as TopologyNodeData | undefined)
1357
+ ?.health;
1358
+ if (nodeHealth === "failed") return "#ef4444";
1359
+ if (nodeHealth === "active") return "#f59e0b";
1360
+ return "#64748b";
1361
+ }}
1362
+ />
1363
+ <Controls
1364
+ position="bottom-right"
1365
+ className="[&>button]:border-white/80 [&>button]:bg-white/90 [&>button]:text-slate-700 [&>button]:shadow-sm"
1366
+ showInteractive={false}
1367
+ />
1368
+ </ReactFlow>
1369
+ ) : (
1370
+ <div className="flex h-full items-center justify-center px-6">
1371
+ <div className="max-w-lg rounded-[28px] border border-white/80 bg-white/80 p-8 text-center shadow-xl backdrop-blur">
1372
+ <div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-slate-100 text-slate-600">
1373
+ <Filter className="h-5 w-5" strokeWidth={1.8} />
1374
+ </div>
1375
+ <h3 className="text-lg font-semibold text-slate-950">
1376
+ No topology matches the current filters
1377
+ </h3>
1378
+ <p className="mt-2 text-sm leading-6 text-slate-600">
1379
+ Try re-enabling a plug, flow, webhook handler, or status chip.
1380
+ The graph only shows connected topology that survives the
1381
+ current filter set.
1382
+ </p>
1383
+ <button
1384
+ type="button"
1385
+ onClick={resetFilters}
1386
+ className="mt-5 inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-slate-300 hover:text-slate-950"
1387
+ >
1388
+ <RotateCcw className="h-4 w-4" strokeWidth={1.8} />
1389
+ Reset filters
1390
+ </button>
1391
+ </div>
1392
+ </div>
1393
+ )}
1394
+ </div>
1395
+ </CardContent>
1396
+ </Card>
1397
+ );
1398
+ }
1399
+
1400
+ export function KhotanTopologyCanvas() {
1401
+ return (
1402
+ <ReactFlowProvider>
1403
+ <TopologyCanvasInner />
1404
+ </ReactFlowProvider>
1405
+ );
1406
+ }