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.
- package/AGENTS.md +54 -0
- package/README.md +117 -1
- package/dist/cli.js +2869 -0
- package/dist/factory.cjs +3303 -0
- package/dist/factory.cjs.map +1 -0
- package/dist/factory.d.cts +662 -0
- package/dist/factory.d.ts +662 -0
- package/dist/factory.js +3292 -0
- package/dist/factory.js.map +1 -0
- package/dist/plug-client.cjs +99 -0
- package/dist/plug-client.cjs.map +1 -0
- package/dist/plug-client.d.cts +71 -0
- package/dist/plug-client.d.ts +71 -0
- package/dist/plug-client.js +96 -0
- package/dist/plug-client.js.map +1 -0
- package/dist/templates/agent-skill.md +73 -0
- package/dist/templates/agents.md +41 -0
- package/dist/templates/cache.example.ts +11 -0
- package/dist/templates/cache.ts +58 -0
- package/dist/templates/catch.example.ts +36 -0
- package/dist/templates/catch.ts +119 -0
- package/dist/templates/config-page.tsx +20 -0
- package/dist/templates/debug-index-page.tsx +101 -0
- package/dist/templates/debug-page.tsx +48 -0
- package/dist/templates/graph-page.tsx +11 -0
- package/dist/templates/hub.tsx +450 -0
- package/dist/templates/inflow.example.ts +61 -0
- package/dist/templates/inflow.ts +98 -0
- package/dist/templates/khotan-config.ts +49 -0
- package/dist/templates/khotan-route.ts +13 -0
- package/dist/templates/logs-page.tsx +9 -0
- package/dist/templates/logs.tsx +20 -0
- package/dist/templates/mapping-browser.tsx +761 -0
- package/dist/templates/mappings-page.tsx +9 -0
- package/dist/templates/outflow.example.ts +52 -0
- package/dist/templates/outflow.ts +90 -0
- package/dist/templates/pass.example.ts +51 -0
- package/dist/templates/pass.ts +134 -0
- package/dist/templates/plug-debugger.tsx +1185 -0
- package/dist/templates/plug.example.ts +93 -0
- package/dist/templates/plug.ts +806 -0
- package/dist/templates/relay.example.ts +71 -0
- package/dist/templates/relay.ts +104 -0
- package/dist/templates/runs-table.tsx +592 -0
- package/dist/templates/schema.ts +505 -0
- package/dist/templates/skill-dashboard.md +144 -0
- package/dist/templates/skill-plug.md +216 -0
- package/dist/templates/skill-setup.md +161 -0
- package/dist/templates/skill-webhook.md +196 -0
- package/dist/templates/topology-canvas.tsx +1406 -0
- package/dist/templates/var-panel.tsx +276 -0
- package/dist/templates/webhook-events-table.tsx +241 -0
- package/dist/templates/wire-panel.tsx +216 -0
- package/dist/templates/wire.ts +155 -0
- 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
|
+
}
|