next-arch-map 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "next-arch-map-viewer",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc --noEmit -p tsconfig.app.json && tsc --noEmit -p tsconfig.node.json && vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "@xyflow/react": "^12.10.1",
13
+ "react": "^19.2.4",
14
+ "react-dom": "^19.2.4"
15
+ },
16
+ "devDependencies": {
17
+ "@types/react": "^19.2.14",
18
+ "@types/react-dom": "^19.2.3",
19
+ "@vitejs/plugin-react": "^6.0.1",
20
+ "typescript": "^5.9.3",
21
+ "vite": "^8.0.0"
22
+ }
23
+ }
@@ -0,0 +1,655 @@
1
+ import { useEffect, useState, type ChangeEvent } from "react";
2
+ import { Filters } from "./Filters";
3
+ import { GraphView } from "./GraphView";
4
+ import { NodeDetails } from "./NodeDetails";
5
+ import type { DiffStatus, EdgeKind, Graph, GraphDiff, Node, NodeType } from "./types";
6
+
7
+ const ALL_NODE_TYPES: NodeType[] = [
8
+ "page",
9
+ "endpoint",
10
+ "handler",
11
+ "action",
12
+ "db",
13
+ "ui",
14
+ ];
15
+ const ALL_EDGE_KINDS: EdgeKind[] = [
16
+ "page-endpoint",
17
+ "endpoint-db",
18
+ "page-ui",
19
+ "endpoint-handler",
20
+ "page-action",
21
+ "action-endpoint",
22
+ ];
23
+
24
+ type LayerPreset = "user-flow" | "data-flow" | "full-flow";
25
+
26
+ const USER_FLOW_EDGE_KINDS: EdgeKind[] = [
27
+ "page-action",
28
+ "action-endpoint",
29
+ "page-endpoint",
30
+ ];
31
+
32
+ const DATA_FLOW_EDGE_KINDS: EdgeKind[] = [
33
+ "endpoint-handler",
34
+ "endpoint-db",
35
+ ];
36
+
37
+ const FULL_FLOW_EDGE_KINDS: EdgeKind[] = [
38
+ "page-endpoint",
39
+ "endpoint-db",
40
+ "page-ui",
41
+ "endpoint-handler",
42
+ "page-action",
43
+ "action-endpoint",
44
+ ];
45
+
46
+ const USER_FLOW_NODE_TYPES: NodeType[] = [
47
+ "page",
48
+ "action",
49
+ "endpoint",
50
+ ];
51
+
52
+ const DATA_FLOW_NODE_TYPES: NodeType[] = [
53
+ "endpoint",
54
+ "handler",
55
+ "db",
56
+ ];
57
+
58
+ const FULL_FLOW_NODE_TYPES: NodeType[] = [
59
+ "page",
60
+ "action",
61
+ "endpoint",
62
+ "handler",
63
+ "db",
64
+ "ui",
65
+ ];
66
+
67
+ function isRecord(value: unknown): value is Record<string, unknown> {
68
+ return !!value && typeof value === "object";
69
+ }
70
+
71
+ function isGraph(value: unknown): value is Graph {
72
+ if (!isRecord(value)) {
73
+ return false;
74
+ }
75
+
76
+ const graph = value as Partial<Graph>;
77
+ return Array.isArray(graph.nodes) && Array.isArray(graph.edges);
78
+ }
79
+
80
+ function isGraphDiff(value: unknown): value is GraphDiff {
81
+ if (!isGraph(value)) {
82
+ return false;
83
+ }
84
+
85
+ const firstNode = value.nodes[0];
86
+ const firstEdge = value.edges[0];
87
+
88
+ return (
89
+ (isRecord(firstNode) && "node" in firstNode && "status" in firstNode) ||
90
+ (isRecord(firstEdge) && "edge" in firstEdge && "status" in firstEdge)
91
+ );
92
+ }
93
+
94
+ function buildEdgeKey(from: string, to: string, kind: EdgeKind): string {
95
+ return `${from}::${to}::${kind}`;
96
+ }
97
+
98
+ export function App() {
99
+ const [graph, setGraph] = useState<Graph | null>(null);
100
+ const [graphDiff, setGraphDiff] = useState<GraphDiff | null>(null);
101
+ const [useServer, setUseServer] = useState(false);
102
+ const [serverUrl, setServerUrl] = useState("http://localhost:4321");
103
+ const [focusedPageRoute, setFocusedPageRoute] = useState<string | null>(null);
104
+ const [queryRoute, setQueryRoute] = useState("/dashboard");
105
+ const [queryResult, setQueryResult] = useState<Node[] | null>(null);
106
+ const [queryError, setQueryError] = useState<string | null>(null);
107
+ const [isQueryLoading, setIsQueryLoading] = useState(false);
108
+ const [visibleNodeTypes, setVisibleNodeTypes] = useState<Set<NodeType>>(
109
+ () => new Set(ALL_NODE_TYPES),
110
+ );
111
+ const [visibleEdgeKinds, setVisibleEdgeKinds] = useState<Set<EdgeKind>>(
112
+ () => new Set(ALL_EDGE_KINDS),
113
+ );
114
+ const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
115
+ const [loadError, setLoadError] = useState<string | null>(null);
116
+
117
+ useEffect(() => {
118
+ setGraph(null);
119
+ setGraphDiff(null);
120
+ setFocusedPageRoute(null);
121
+ setQueryResult(null);
122
+ setQueryError(null);
123
+ setSelectedNodeId(null);
124
+ setLoadError(null);
125
+ }, [useServer]);
126
+
127
+ useEffect(() => {
128
+ if (!useServer) {
129
+ return;
130
+ }
131
+
132
+ let cancelled = false;
133
+
134
+ async function poll(): Promise<void> {
135
+ try {
136
+ const graphResponse = await fetch(buildServerEndpoint(serverUrl, "/graph"));
137
+ if (!graphResponse.ok) {
138
+ if (!cancelled) {
139
+ setGraph(null);
140
+ setGraphDiff(null);
141
+ setSelectedNodeId(null);
142
+ setLoadError(`Server graph request failed: ${graphResponse.status}`);
143
+ }
144
+ return;
145
+ }
146
+
147
+ const nextGraph = (await graphResponse.json()) as Graph;
148
+ if (!cancelled) {
149
+ setGraph(nextGraph);
150
+ setLoadError(null);
151
+ }
152
+
153
+ try {
154
+ const diffResponse = await fetch(buildServerEndpoint(serverUrl, "/diff"));
155
+ if (!diffResponse.ok) {
156
+ if (!cancelled) {
157
+ setGraphDiff(null);
158
+ }
159
+ return;
160
+ }
161
+
162
+ const nextDiff = (await diffResponse.json()) as GraphDiff;
163
+ if (!cancelled) {
164
+ setGraphDiff(nextDiff);
165
+ }
166
+ } catch (error) {
167
+ console.error("Error fetching diff from server", error);
168
+ if (!cancelled) {
169
+ setGraphDiff(null);
170
+ }
171
+ }
172
+ } catch (error) {
173
+ console.error("Error fetching graph from server", error);
174
+ if (!cancelled) {
175
+ setGraph(null);
176
+ setGraphDiff(null);
177
+ setSelectedNodeId(null);
178
+ setLoadError("Failed to fetch graph from server.");
179
+ }
180
+ }
181
+ }
182
+
183
+ void poll();
184
+ const interval = window.setInterval(() => {
185
+ void poll();
186
+ }, 5000);
187
+
188
+ return () => {
189
+ cancelled = true;
190
+ window.clearInterval(interval);
191
+ };
192
+ }, [serverUrl, useServer]);
193
+
194
+ const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
195
+ const file = event.target.files?.[0];
196
+ if (!file) return;
197
+
198
+ const reader = new FileReader();
199
+ reader.onload = () => {
200
+ try {
201
+ const parsed = JSON.parse(String(reader.result)) as unknown;
202
+ if (isGraphDiff(parsed)) {
203
+ setGraphDiff(parsed);
204
+ setGraph(null);
205
+ setSelectedNodeId(null);
206
+ setLoadError(null);
207
+ return;
208
+ }
209
+
210
+ if (!isGraph(parsed)) {
211
+ throw new Error("Invalid graph shape");
212
+ }
213
+
214
+ setGraph(parsed);
215
+ setGraphDiff(null);
216
+ setSelectedNodeId(null);
217
+ setLoadError(null);
218
+ } catch (error) {
219
+ console.error("Failed to parse graph JSON", error);
220
+ setGraph(null);
221
+ setGraphDiff(null);
222
+ setSelectedNodeId(null);
223
+ setLoadError("Failed to parse graph JSON.");
224
+ }
225
+ };
226
+ reader.onerror = () => {
227
+ setGraph(null);
228
+ setGraphDiff(null);
229
+ setSelectedNodeId(null);
230
+ setLoadError("Failed to read the selected file.");
231
+ };
232
+ reader.readAsText(file);
233
+ };
234
+
235
+ const toggleNodeType = (type: NodeType) => {
236
+ setVisibleNodeTypes((prev) => {
237
+ const next = new Set(prev);
238
+ if (next.has(type)) next.delete(type);
239
+ else next.add(type);
240
+ return next;
241
+ });
242
+ };
243
+
244
+ const toggleEdgeKind = (kind: EdgeKind) => {
245
+ setVisibleEdgeKinds((prev) => {
246
+ const next = new Set(prev);
247
+ if (next.has(kind)) next.delete(kind);
248
+ else next.add(kind);
249
+ return next;
250
+ });
251
+ };
252
+
253
+ const applyPreset = (preset: LayerPreset) => {
254
+ if (preset === "user-flow") {
255
+ setVisibleNodeTypes(new Set(USER_FLOW_NODE_TYPES));
256
+ setVisibleEdgeKinds(new Set(USER_FLOW_EDGE_KINDS));
257
+ return;
258
+ }
259
+
260
+ if (preset === "data-flow") {
261
+ setVisibleNodeTypes(new Set(DATA_FLOW_NODE_TYPES));
262
+ setVisibleEdgeKinds(new Set(DATA_FLOW_EDGE_KINDS));
263
+ return;
264
+ }
265
+
266
+ setVisibleNodeTypes(new Set(FULL_FLOW_NODE_TYPES));
267
+ setVisibleEdgeKinds(new Set(FULL_FLOW_EDGE_KINDS));
268
+ };
269
+
270
+ const handlePageToDbQuery = async () => {
271
+ setIsQueryLoading(true);
272
+ setQueryError(null);
273
+
274
+ try {
275
+ const response = await fetch(
276
+ buildServerEndpoint(
277
+ serverUrl,
278
+ `/query/page-to-db?route=${encodeURIComponent(queryRoute || "/")}`,
279
+ ),
280
+ );
281
+
282
+ if (!response.ok) {
283
+ throw new Error(`Query request failed: ${response.status}`);
284
+ }
285
+
286
+ const payload = (await response.json()) as { route: string; dbModels: Node[] };
287
+ setQueryResult(payload.dbModels);
288
+ } catch (error) {
289
+ console.error("Error fetching page-to-db query", error);
290
+ setQueryResult(null);
291
+ setQueryError("Failed to fetch page -> db query.");
292
+ } finally {
293
+ setIsQueryLoading(false);
294
+ }
295
+ };
296
+
297
+ const baseGraph = getRenderedGraph(graph, graphDiff);
298
+ const pageRoutes = getPageRoutes(baseGraph);
299
+ const renderedGraph =
300
+ focusedPageRoute && baseGraph
301
+ ? buildFocusedSubgraph(baseGraph, focusedPageRoute)
302
+ : baseGraph;
303
+ const selectedNode =
304
+ renderedGraph?.nodes.find((node) => node.id === selectedNodeId) ?? null;
305
+ const graphModeLabel = graphDiff
306
+ ? "Diff mode"
307
+ : graph
308
+ ? "Graph mode"
309
+ : useServer
310
+ ? "Server mode"
311
+ : "No file loaded";
312
+ const emptyStateLabel = useServer
313
+ ? "Waiting for graph from server."
314
+ : "Select a graph JSON file to visualize.";
315
+ const nodeStatusById = graphDiff ? buildNodeStatusById(graphDiff) : undefined;
316
+ const edgeStatusByKey = graphDiff ? buildEdgeStatusByKey(graphDiff) : undefined;
317
+
318
+ useEffect(() => {
319
+ if (!baseGraph || pageRoutes.length === 0) {
320
+ setFocusedPageRoute(null);
321
+ return;
322
+ }
323
+
324
+ if (focusedPageRoute && !pageRoutes.includes(focusedPageRoute)) {
325
+ setFocusedPageRoute(null);
326
+ }
327
+ }, [baseGraph, focusedPageRoute, pageRoutes]);
328
+
329
+ return (
330
+ <div
331
+ style={{
332
+ display: "flex",
333
+ height: "100vh",
334
+ background: "linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%)",
335
+ }}
336
+ >
337
+ <aside
338
+ style={{
339
+ width: 260,
340
+ padding: 16,
341
+ borderRight: "1px solid rgba(148, 163, 184, 0.35)",
342
+ boxSizing: "border-box",
343
+ background: "rgba(255, 255, 255, 0.88)",
344
+ backdropFilter: "blur(10px)",
345
+ overflowY: "auto",
346
+ }}
347
+ >
348
+ <h1 style={{ fontSize: 18, marginBottom: 12 }}>next-arch-map viewer</h1>
349
+
350
+ <div style={{ marginBottom: 16 }}>
351
+ <label style={{ display: "block", fontSize: 13 }}>
352
+ <input
353
+ type="checkbox"
354
+ checked={useServer}
355
+ onChange={(event) => setUseServer(event.target.checked)}
356
+ />{" "}
357
+ Use server (auto-refresh)
358
+ </label>
359
+ {useServer && (
360
+ <input
361
+ type="text"
362
+ value={serverUrl}
363
+ onChange={(event) => setServerUrl(event.target.value)}
364
+ style={{ width: "100%", marginTop: 4, fontSize: 12 }}
365
+ placeholder="http://localhost:4321"
366
+ />
367
+ )}
368
+ </div>
369
+
370
+ <div style={{ marginBottom: 16 }}>
371
+ <label style={{ display: "block", marginBottom: 4, fontSize: 13 }}>
372
+ Load graph or diff JSON
373
+ </label>
374
+ <input
375
+ type="file"
376
+ accept="application/json,.json"
377
+ onChange={handleFileChange}
378
+ disabled={useServer}
379
+ />
380
+ </div>
381
+
382
+ {useServer && (
383
+ <div
384
+ style={{
385
+ marginBottom: 16,
386
+ padding: 12,
387
+ borderRadius: 8,
388
+ background: "#f8fafc",
389
+ border: "1px solid rgba(148, 163, 184, 0.25)",
390
+ }}
391
+ >
392
+ <div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>
393
+ Page to DB
394
+ </div>
395
+ <input
396
+ type="text"
397
+ value={queryRoute}
398
+ onChange={(event) => setQueryRoute(event.target.value)}
399
+ style={{ width: "100%", marginBottom: 8, fontSize: 12 }}
400
+ placeholder="/dashboard"
401
+ />
402
+ <button
403
+ type="button"
404
+ onClick={() => void handlePageToDbQuery()}
405
+ disabled={isQueryLoading}
406
+ style={{
407
+ width: "100%",
408
+ padding: "8px 10px",
409
+ borderRadius: 6,
410
+ border: "1px solid #cbd5e1",
411
+ background: "#fff",
412
+ fontSize: 12,
413
+ cursor: isQueryLoading ? "wait" : "pointer",
414
+ }}
415
+ >
416
+ {isQueryLoading ? "Loading..." : "Show DB for page"}
417
+ </button>
418
+
419
+ {queryError && (
420
+ <div style={{ marginTop: 8, fontSize: 12, color: "#991b1b" }}>{queryError}</div>
421
+ )}
422
+
423
+ {queryResult && (
424
+ <div style={{ marginTop: 8, fontSize: 12, color: "#334155" }}>
425
+ {queryResult.length > 0 ? (
426
+ <pre
427
+ style={{
428
+ margin: 0,
429
+ padding: 8,
430
+ background: "#fff",
431
+ borderRadius: 6,
432
+ overflow: "auto",
433
+ }}
434
+ >
435
+ {JSON.stringify(queryResult, null, 2)}
436
+ </pre>
437
+ ) : (
438
+ <div>No DB models found for this page.</div>
439
+ )}
440
+ </div>
441
+ )}
442
+ </div>
443
+ )}
444
+
445
+ {baseGraph && pageRoutes.length > 0 && (
446
+ <div style={{ marginBottom: 16 }}>
447
+ <label style={{ display: "block", marginBottom: 4, fontSize: 13 }}>
448
+ Focused page
449
+ </label>
450
+ <select
451
+ value={focusedPageRoute ?? ""}
452
+ onChange={(event) => setFocusedPageRoute(event.target.value || null)}
453
+ style={{ width: "100%", fontSize: 13 }}
454
+ >
455
+ <option value="">(All pages)</option>
456
+ {pageRoutes.map((route) => (
457
+ <option key={route} value={route}>
458
+ {route}
459
+ </option>
460
+ ))}
461
+ </select>
462
+ </div>
463
+ )}
464
+
465
+ <div
466
+ style={{
467
+ marginBottom: 16,
468
+ borderRadius: 8,
469
+ padding: "10px 12px",
470
+ background: graphDiff ? "#ecfdf5" : "#eff6ff",
471
+ color: graphDiff ? "#166534" : "#1d4ed8",
472
+ fontSize: 12,
473
+ }}
474
+ >
475
+ {graphModeLabel}
476
+ </div>
477
+
478
+ {loadError && (
479
+ <div
480
+ style={{
481
+ marginBottom: 16,
482
+ borderRadius: 8,
483
+ padding: "10px 12px",
484
+ background: "#fee2e2",
485
+ color: "#991b1b",
486
+ fontSize: 12,
487
+ }}
488
+ >
489
+ {loadError}
490
+ </div>
491
+ )}
492
+
493
+ <div style={{ marginBottom: 12 }}>
494
+ <div style={{ fontSize: 13, marginBottom: 4 }}>View preset</div>
495
+ <div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
496
+ <button
497
+ type="button"
498
+ style={{ fontSize: 11, padding: "2px 6px" }}
499
+ onClick={() => applyPreset("user-flow")}
500
+ >
501
+ User Flow
502
+ </button>
503
+ <button
504
+ type="button"
505
+ style={{ fontSize: 11, padding: "2px 6px" }}
506
+ onClick={() => applyPreset("data-flow")}
507
+ >
508
+ Data Flow
509
+ </button>
510
+ <button
511
+ type="button"
512
+ style={{ fontSize: 11, padding: "2px 6px" }}
513
+ onClick={() => applyPreset("full-flow")}
514
+ >
515
+ Full Flow
516
+ </button>
517
+ </div>
518
+ </div>
519
+
520
+ <Filters
521
+ allNodeTypes={ALL_NODE_TYPES}
522
+ allEdgeKinds={ALL_EDGE_KINDS}
523
+ visibleNodeTypes={visibleNodeTypes}
524
+ visibleEdgeKinds={visibleEdgeKinds}
525
+ onToggleNodeType={toggleNodeType}
526
+ onToggleEdgeKind={toggleEdgeKind}
527
+ />
528
+
529
+ <NodeDetails node={selectedNode} />
530
+ </aside>
531
+
532
+ <main style={{ flex: 1, minWidth: 0 }}>
533
+ {renderedGraph ? (
534
+ <GraphView
535
+ graph={renderedGraph}
536
+ visibleNodeTypes={visibleNodeTypes}
537
+ visibleEdgeKinds={visibleEdgeKinds}
538
+ onSelectNode={setSelectedNodeId}
539
+ selectedNodeId={selectedNodeId}
540
+ nodeStatusById={nodeStatusById}
541
+ edgeStatusByKey={edgeStatusByKey}
542
+ />
543
+ ) : (
544
+ <div
545
+ style={{
546
+ height: "100%",
547
+ display: "flex",
548
+ alignItems: "center",
549
+ justifyContent: "center",
550
+ color: "#475569",
551
+ padding: 24,
552
+ boxSizing: "border-box",
553
+ }}
554
+ >
555
+ <p>{emptyStateLabel}</p>
556
+ </div>
557
+ )}
558
+ </main>
559
+ </div>
560
+ );
561
+ }
562
+
563
+ function buildServerEndpoint(baseUrl: string, pathname: string): string {
564
+ const normalizedBase = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
565
+ return new URL(pathname.replace(/^\//, ""), normalizedBase).toString();
566
+ }
567
+
568
+ function getRenderedGraph(graph: Graph | null, graphDiff: GraphDiff | null): Graph | null {
569
+ if (graphDiff) {
570
+ return {
571
+ nodes: graphDiff.nodes.map((nodeDiff) => nodeDiff.node),
572
+ edges: graphDiff.edges.map((edgeDiff) => edgeDiff.edge),
573
+ };
574
+ }
575
+
576
+ return graph;
577
+ }
578
+
579
+ function getPageRoutes(graph: Graph | null): string[] {
580
+ if (!graph) {
581
+ return [];
582
+ }
583
+
584
+ return graph.nodes
585
+ .filter((node) => node.type === "page")
586
+ .map((node) => node.label)
587
+ .sort((left, right) => left.localeCompare(right));
588
+ }
589
+
590
+ function buildFocusedSubgraph(graph: Graph, route: string): Graph {
591
+ const pageId = `page:${route}`;
592
+ const pageNode = graph.nodes.find((node) => node.id === pageId);
593
+
594
+ if (!pageNode) {
595
+ return graph;
596
+ }
597
+
598
+ const allowedEdgeKinds = new Set<EdgeKind>([
599
+ "page-action",
600
+ "action-endpoint",
601
+ "page-endpoint",
602
+ "endpoint-handler",
603
+ "endpoint-db",
604
+ "page-ui",
605
+ ]);
606
+ const reachableNodeIds = new Set<string>([pageId]);
607
+ const worklist = [pageId];
608
+
609
+ while (worklist.length > 0) {
610
+ const currentId = worklist.pop();
611
+ if (!currentId) {
612
+ continue;
613
+ }
614
+
615
+ for (const edge of graph.edges) {
616
+ if (!allowedEdgeKinds.has(edge.kind)) {
617
+ continue;
618
+ }
619
+
620
+ if (edge.from === currentId && !reachableNodeIds.has(edge.to)) {
621
+ reachableNodeIds.add(edge.to);
622
+ worklist.push(edge.to);
623
+ }
624
+ }
625
+ }
626
+
627
+ const nodes = graph.nodes.filter((node) => reachableNodeIds.has(node.id));
628
+ const nodeIds = new Set(nodes.map((node) => node.id));
629
+ const edges = graph.edges.filter((edge) => nodeIds.has(edge.from) && nodeIds.has(edge.to));
630
+
631
+ return { nodes, edges };
632
+ }
633
+
634
+ function buildNodeStatusById(graphDiff: GraphDiff): Map<string, DiffStatus> {
635
+ const nodeStatusById = new Map<string, DiffStatus>();
636
+
637
+ for (const nodeDiff of graphDiff.nodes) {
638
+ nodeStatusById.set(nodeDiff.node.id, nodeDiff.status);
639
+ }
640
+
641
+ return nodeStatusById;
642
+ }
643
+
644
+ function buildEdgeStatusByKey(graphDiff: GraphDiff): Map<string, DiffStatus> {
645
+ const edgeStatusByKey = new Map<string, DiffStatus>();
646
+
647
+ for (const edgeDiff of graphDiff.edges) {
648
+ edgeStatusByKey.set(
649
+ buildEdgeKey(edgeDiff.edge.from, edgeDiff.edge.to, edgeDiff.edge.kind),
650
+ edgeDiff.status,
651
+ );
652
+ }
653
+
654
+ return edgeStatusByKey;
655
+ }