servicemap 0.1.31 → 0.1.34
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/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
4
5
|
import { dirname, extname, isAbsolute, join, resolve } from 'node:path';
|
|
5
6
|
|
|
6
7
|
const defaultInput = '.servicemap/internal-servicemap.json';
|
|
@@ -21,6 +22,14 @@ async function readJson(path) {
|
|
|
21
22
|
return JSON.parse(await readFile(path, 'utf8'));
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
async function readJsonOrNull(path) {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
24
33
|
function routeFor(root, framework) {
|
|
25
34
|
if (framework === 'next-app-router') {
|
|
26
35
|
const base = existsSync(join(root, 'src/app')) ? 'src/app' : 'app';
|
|
@@ -64,6 +73,7 @@ function pageCss() {
|
|
|
64
73
|
.mode-button { min-height: 36px; border: 0; border-radius: 8px; background: transparent; color: #789086; padding: 0 12px; cursor: pointer; font: inherit; font-weight: 800; }
|
|
65
74
|
.mode-button.is-active { background: #276f53; color: #c9f9df; box-shadow: 0 0 18px rgba(101,240,173,0.16); }
|
|
66
75
|
.map { position: relative; height: calc(100vh - 88px); min-height: 760px; background: radial-gradient(circle at 50% 52%, rgba(41, 96, 69, 0.22), transparent 24%), radial-gradient(circle at 1px 1px, rgba(88, 160, 120, 0.2) 1px, transparent 1px), #020806; background-size: auto, 28px 28px, auto; }
|
|
76
|
+
.map-plane { position: absolute; inset: 0; transform: scale(var(--zoom, 1)); transform-origin: 50% 50%; transition: transform 160ms ease; }
|
|
67
77
|
.intro { position: absolute; left: 32px; top: 32px; z-index: 20; }
|
|
68
78
|
.intro strong { display: block; color: #779286; font-size: 12px; letter-spacing: 0.22em; text-transform: uppercase; }
|
|
69
79
|
.intro span { display: block; margin-top: 8px; color: #53665d; font-size: 14px; }
|
|
@@ -87,6 +97,11 @@ function pageCss() {
|
|
|
87
97
|
.status-medium, .status-low { color: #f1c85b; background: #f1c85b; }
|
|
88
98
|
.legend { position: absolute; left: 50%; bottom: 32px; z-index: 20; transform: translateX(-50%); display: flex; align-items: center; gap: 20px; border: 1px solid rgba(255,255,255,0.1); border-radius: 999px; background: rgba(11,20,15,0.9); color: #789086; padding: 8px 20px; font-size: 12px; }
|
|
89
99
|
.legend i { display: inline-block; width: 8px; height: 8px; margin-right: 8px; border-radius: 999px; }
|
|
100
|
+
.zoom-controls { position: absolute; left: 28px; bottom: 26px; z-index: 29; display: grid; overflow: hidden; border: 1px solid rgba(255,255,255,0.12); border-radius: 10px; background: rgba(7,14,10,0.9); box-shadow: 0 12px 32px rgba(0,0,0,0.34); }
|
|
101
|
+
.zoom-button { width: 38px; height: 36px; border: 0; border-bottom: 1px solid rgba(255,255,255,0.1); background: transparent; color: #a8bbb2; cursor: pointer; font: inherit; font-size: 22px; line-height: 1; }
|
|
102
|
+
.zoom-button:last-child { border-bottom: 0; font-size: 18px; }
|
|
103
|
+
.zoom-button:hover { background: rgba(101,240,173,0.1); color: #d8f8e8; }
|
|
104
|
+
.fit-icon { display: inline-block; width: 15px; height: 15px; border: 2px solid currentColor; border-radius: 3px; }
|
|
90
105
|
.scan-foot { position: absolute; right: 32px; bottom: 32px; z-index: 20; color: #53665d; text-align: right; font-size: 12px; line-height: 1.6; }
|
|
91
106
|
.access-warning { position: absolute; left: 32px; right: 32px; bottom: 88px; z-index: 25; max-width: 720px; border: 1px solid rgba(241,200,91,0.42); border-radius: 8px; background: rgba(31,27,12,0.92); color: #f7daa0; padding: 12px 14px; font-size: 14px; line-height: 1.45; }
|
|
92
107
|
.drawer-scrim { position: absolute; inset: 0; z-index: 24; background: rgba(0,0,0,0.58); pointer-events: none; }
|
|
@@ -114,17 +129,19 @@ function pageCss() {
|
|
|
114
129
|
.delete-button { color: #ff6b57; background: transparent; font-weight: 800; }
|
|
115
130
|
.save-button { min-height: 48px; border-radius: 10px; background: #287a5a; color: #effbf5; padding: 0 18px; font-weight: 800; box-shadow: 0 10px 30px rgba(54,180,126,0.18); }
|
|
116
131
|
.helper-text { margin: 10px 0 0; color: #687c72; font-size: 12px; line-height: 1.45; }
|
|
117
|
-
.
|
|
118
|
-
.
|
|
119
|
-
.
|
|
120
|
-
.
|
|
121
|
-
.
|
|
122
|
-
.
|
|
123
|
-
.
|
|
124
|
-
.
|
|
125
|
-
.
|
|
126
|
-
.
|
|
127
|
-
.
|
|
132
|
+
.graph-3d { position: absolute; inset: 0; overflow: hidden; }
|
|
133
|
+
.graph-3d::before { content: ''; position: absolute; z-index: 1; inset: 0; pointer-events: none; background: radial-gradient(circle at 50% 50%, transparent 38%, rgba(2,5,4,.46) 100%); }
|
|
134
|
+
.graph-3d canvas { display: block; cursor: grab; }
|
|
135
|
+
.graph-3d canvas:active { cursor: grabbing; }
|
|
136
|
+
.graph-3d__help, .graph-3d__focus { position: absolute; z-index: 3; bottom: 20px; border: 1px solid rgba(255,255,255,.07); background: rgba(9,14,11,.74); color: #718078; font-size: 8px; backdrop-filter: blur(14px); pointer-events: none; }
|
|
137
|
+
.graph-3d__help { left: 50%; transform: translateX(-50%); display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 999px; white-space: nowrap; }
|
|
138
|
+
.graph-3d__help span { width: 3px; height: 3px; border-radius: 50%; background: #4a5a52; }
|
|
139
|
+
.graph-3d__focus { left: 22px; padding: 8px 10px; border-radius: 8px; color: #91bba7; }
|
|
140
|
+
.graph-3d__zoom { position: absolute; z-index: 4; right: 22px; bottom: 20px; display: flex; flex-direction: column; overflow: hidden; border: 1px solid rgba(255,255,255,.1); border-radius: 10px; background: rgba(9,14,11,.82); box-shadow: 0 10px 28px rgba(0,0,0,.3); backdrop-filter: blur(14px); }
|
|
141
|
+
.graph-3d__zoom button { width: 36px; height: 34px; padding: 0; border: 0; display: grid; place-items: center; background: transparent; color: #aebbb4; cursor: pointer; transition: color .18s ease, background .18s ease; font-size: 20px; }
|
|
142
|
+
.graph-3d__zoom button + button { border-top: 1px solid rgba(255,255,255,.08); }
|
|
143
|
+
.graph-3d__zoom button:hover { color: #fff; background: rgba(255,255,255,.08); }
|
|
144
|
+
.graph-3d__loading { position: absolute; inset: 0; display: grid; place-items: center; color: #75857c; font-size: 9px; }
|
|
128
145
|
@keyframes servicemapDash { to { stroke-dashoffset: -1.3; } }
|
|
129
146
|
@keyframes servicemapLinkFlow { to { background-position: 22px 0; } }
|
|
130
147
|
@media (max-width: 920px) {
|
|
@@ -245,7 +262,11 @@ ${htmlBody(graph)}
|
|
|
245
262
|
function interactiveTsxPage(graph) {
|
|
246
263
|
return `'use client';
|
|
247
264
|
|
|
248
|
-
import
|
|
265
|
+
import dynamic from 'next/dynamic';
|
|
266
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
|
267
|
+
import type { ForceGraphMethods, NodeObject } from 'react-force-graph-3d';
|
|
268
|
+
import * as THREE from 'three';
|
|
269
|
+
import SpriteText from 'three-spritetext';
|
|
249
270
|
|
|
250
271
|
const css = ${JSON.stringify(pageCss())};
|
|
251
272
|
type Evidence = { type?: string; value?: string; confidence?: string };
|
|
@@ -253,10 +274,15 @@ type RawService = { id: string; name: string; key?: string; category?: string; c
|
|
|
253
274
|
type ServiceNode = RawService & { color: string; x: number; y: number };
|
|
254
275
|
type DraftDetails = { subtitle: string; use: string; account: string; passwordLocation: string };
|
|
255
276
|
type ServiceLink = { label: string; url: string };
|
|
277
|
+
type GraphNode3D = { id: string; name: string; category: string; color: string; iconUrl?: string | null; monogram: string; isProduct: boolean; x?: number; y?: number; z?: number };
|
|
278
|
+
type GraphLink3D = { id: string; source: string | GraphNode3D; target: string | GraphNode3D; label: string };
|
|
279
|
+
type AdjustableForce = { strength?: (value: number) => unknown; distance?: (value: number) => unknown };
|
|
280
|
+
type OrbitControlsLike = { target?: THREE.Vector3 };
|
|
256
281
|
|
|
257
282
|
const graph = ${JSON.stringify(graph)} as { generatedAt: string; project?: { name?: string; logoUrl?: string }; services?: RawService[] };
|
|
258
283
|
const palette = ${JSON.stringify(palette)} as string[];
|
|
259
284
|
const layout = ${JSON.stringify(layout)} as Array<{ x: number; y: number }>;
|
|
285
|
+
const ForceGraph3D = dynamic(() => import('react-force-graph-3d'), { ssr: false });
|
|
260
286
|
|
|
261
287
|
function categoryLabel(value?: string | null) {
|
|
262
288
|
return String(value ?? 'service').replace(/-/g, ' ');
|
|
@@ -274,68 +300,85 @@ function edgePath(node: ServiceNode) {
|
|
|
274
300
|
return \`M \${fromX} \${node.y} H \${midX} V 50 H \${endX}\`;
|
|
275
301
|
}
|
|
276
302
|
|
|
277
|
-
function
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
{ x: 230, y: -135, z: 24 },
|
|
281
|
-
{ x: -220, y: -120, z: 10 },
|
|
282
|
-
{ x: 165, y: 88, z: 16 },
|
|
283
|
-
{ x: -170, y: 118, z: -10 },
|
|
284
|
-
{ x: 365, y: 42, z: 34 },
|
|
285
|
-
{ x: -360, y: 52, z: -18 },
|
|
286
|
-
{ x: 265, y: 250, z: -32 },
|
|
287
|
-
{ x: -265, y: -270, z: 28 },
|
|
288
|
-
{ x: 430, y: -190, z: -20 },
|
|
289
|
-
{ x: -430, y: 232, z: 22 },
|
|
290
|
-
{ x: 40, y: 312, z: -38 },
|
|
291
|
-
{ x: -60, y: -350, z: 18 },
|
|
292
|
-
{ x: 520, y: 126, z: 8 },
|
|
293
|
-
{ x: -520, y: -36, z: -26 },
|
|
294
|
-
{ x: 350, y: -330, z: 30 },
|
|
295
|
-
];
|
|
296
|
-
const fallbackAngle = index * 2.399963229728653;
|
|
297
|
-
const fallbackRadius = 250 + (index % 5) * 52;
|
|
298
|
-
const point = constellation[index] ?? {
|
|
299
|
-
x: Math.cos(fallbackAngle) * fallbackRadius,
|
|
300
|
-
y: Math.sin(fallbackAngle) * fallbackRadius * 0.72,
|
|
301
|
-
z: ((index % 7) - 3) * 14,
|
|
302
|
-
};
|
|
303
|
-
const angle = Math.atan2(point.y, point.x);
|
|
304
|
-
const length = Math.hypot(point.x, point.y);
|
|
305
|
-
const scale = 0.74 + Math.max(-36, Math.min(48, point.z)) / 220;
|
|
306
|
-
return {
|
|
307
|
-
x: point.x,
|
|
308
|
-
y: point.y,
|
|
309
|
-
z: point.z,
|
|
310
|
-
angle: (angle * 180) / Math.PI,
|
|
311
|
-
length,
|
|
312
|
-
scale,
|
|
313
|
-
};
|
|
303
|
+
function primaryEvidence(service: RawService) {
|
|
304
|
+
const evidence = service.evidence?.[0];
|
|
305
|
+
return evidence ? \`\${evidence.type}: \${evidence.value}\` : 'No evidence captured yet.';
|
|
314
306
|
}
|
|
315
307
|
|
|
316
|
-
function
|
|
317
|
-
|
|
318
|
-
return {
|
|
319
|
-
'--x': \`\${position.x}px\`,
|
|
320
|
-
'--y': \`\${position.y}px\`,
|
|
321
|
-
'--z': \`\${position.z}px\`,
|
|
322
|
-
'--scale': String(position.scale),
|
|
323
|
-
'--color': color,
|
|
324
|
-
} as CSSProperties;
|
|
308
|
+
function endpointId(endpoint: string | number | GraphNode3D) {
|
|
309
|
+
return typeof endpoint === 'object' ? endpoint.id : String(endpoint);
|
|
325
310
|
}
|
|
326
311
|
|
|
327
|
-
function
|
|
328
|
-
|
|
329
|
-
return {
|
|
330
|
-
'--angle': \`\${position.angle}deg\`,
|
|
331
|
-
'--length': \`\${position.length}px\`,
|
|
332
|
-
'--z': \`\${position.z}px\`,
|
|
333
|
-
} as CSSProperties;
|
|
312
|
+
function graphNode(node: NodeObject) {
|
|
313
|
+
return node as GraphNode3D;
|
|
334
314
|
}
|
|
335
315
|
|
|
336
|
-
function
|
|
337
|
-
|
|
338
|
-
|
|
316
|
+
function graphLink(link: GraphLink3D) {
|
|
317
|
+
return link;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function iconObject(node: GraphNode3D, selected: boolean) {
|
|
321
|
+
const group = new THREE.Group();
|
|
322
|
+
const radius = node.isProduct ? 10 : 7;
|
|
323
|
+
const shell = new THREE.Mesh(
|
|
324
|
+
new THREE.SphereGeometry(radius, 32, 32),
|
|
325
|
+
new THREE.MeshStandardMaterial({
|
|
326
|
+
color: new THREE.Color(node.color),
|
|
327
|
+
emissive: new THREE.Color(node.color),
|
|
328
|
+
emissiveIntensity: selected ? 0.65 : 0.23,
|
|
329
|
+
metalness: 0.18,
|
|
330
|
+
roughness: 0.48,
|
|
331
|
+
}),
|
|
332
|
+
);
|
|
333
|
+
group.add(shell);
|
|
334
|
+
|
|
335
|
+
const halo = new THREE.Mesh(
|
|
336
|
+
new THREE.SphereGeometry(radius + (selected ? 2.2 : 1.25), 24, 24),
|
|
337
|
+
new THREE.MeshBasicMaterial({ color: selected ? '#9ff0cb' : node.color, transparent: true, opacity: selected ? 0.18 : 0.07, side: THREE.BackSide }),
|
|
338
|
+
);
|
|
339
|
+
group.add(halo);
|
|
340
|
+
|
|
341
|
+
const fallback = new SpriteText(node.monogram.slice(0, 3));
|
|
342
|
+
fallback.color = '#ffffff';
|
|
343
|
+
fallback.textHeight = node.isProduct ? 4.2 : 3.2;
|
|
344
|
+
fallback.fontSize = 180;
|
|
345
|
+
fallback.fontWeight = '700';
|
|
346
|
+
fallback.renderOrder = 10;
|
|
347
|
+
fallback.material.depthTest = false;
|
|
348
|
+
fallback.material.depthWrite = false;
|
|
349
|
+
group.add(fallback);
|
|
350
|
+
|
|
351
|
+
if (node.iconUrl) {
|
|
352
|
+
new THREE.TextureLoader().load(node.iconUrl, (texture) => {
|
|
353
|
+
texture.colorSpace = THREE.SRGBColorSpace;
|
|
354
|
+
texture.anisotropy = 16;
|
|
355
|
+
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
|
356
|
+
texture.magFilter = THREE.LinearFilter;
|
|
357
|
+
texture.generateMipmaps = true;
|
|
358
|
+
texture.needsUpdate = true;
|
|
359
|
+
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false, depthWrite: false, alphaTest: 0.015 }));
|
|
360
|
+
const size = node.isProduct ? 12 : 8.5;
|
|
361
|
+
sprite.scale.set(size, size, 1);
|
|
362
|
+
sprite.renderOrder = 10;
|
|
363
|
+
group.add(sprite);
|
|
364
|
+
fallback.visible = false;
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const label = new SpriteText(node.name);
|
|
369
|
+
label.color = selected ? '#b9f6da' : '#dfeae4';
|
|
370
|
+
label.textHeight = selected ? 5.4 : 4.7;
|
|
371
|
+
label.fontSize = 220;
|
|
372
|
+
label.fontWeight = selected ? '700' : '600';
|
|
373
|
+
label.backgroundColor = 'rgba(5, 8, 7, .72)';
|
|
374
|
+
label.padding = 1.5;
|
|
375
|
+
label.borderRadius = 2;
|
|
376
|
+
label.position.y = -(radius + 6.8);
|
|
377
|
+
label.renderOrder = 11;
|
|
378
|
+
label.material.depthTest = false;
|
|
379
|
+
label.material.depthWrite = false;
|
|
380
|
+
group.add(label);
|
|
381
|
+
return group;
|
|
339
382
|
}
|
|
340
383
|
|
|
341
384
|
function serviceLinks(service: RawService): ServiceLink[] {
|
|
@@ -468,6 +511,170 @@ function DetailsPanel({
|
|
|
468
511
|
);
|
|
469
512
|
}
|
|
470
513
|
|
|
514
|
+
function ServiceGraph3D({
|
|
515
|
+
nodes,
|
|
516
|
+
selectedId,
|
|
517
|
+
onSelect,
|
|
518
|
+
}: {
|
|
519
|
+
nodes: ServiceNode[];
|
|
520
|
+
selectedId: string | null;
|
|
521
|
+
onSelect: (id: string | null) => void;
|
|
522
|
+
}) {
|
|
523
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
524
|
+
const graphRef = useRef<ForceGraphMethods<GraphNode3D, GraphLink3D> | undefined>(undefined);
|
|
525
|
+
const [size, setSize] = useState({ width: 900, height: 700 });
|
|
526
|
+
const hasFramed = useRef(false);
|
|
527
|
+
const graphData = useMemo(() => ({
|
|
528
|
+
nodes: [{
|
|
529
|
+
id: 'project',
|
|
530
|
+
name: graph.project?.name ?? 'Project',
|
|
531
|
+
category: 'product',
|
|
532
|
+
color: '#8be4b2',
|
|
533
|
+
iconUrl: graph.project?.logoUrl,
|
|
534
|
+
monogram: initials(graph.project?.name ?? 'Project'),
|
|
535
|
+
isProduct: true,
|
|
536
|
+
}, ...nodes.map((node): GraphNode3D => ({
|
|
537
|
+
id: node.id,
|
|
538
|
+
name: node.name,
|
|
539
|
+
category: categoryLabel(node.category),
|
|
540
|
+
color: node.color,
|
|
541
|
+
iconUrl: node.iconUrl,
|
|
542
|
+
monogram: initials(node.name),
|
|
543
|
+
isProduct: node.category === 'product',
|
|
544
|
+
}))],
|
|
545
|
+
links: nodes.map((node): GraphLink3D => ({
|
|
546
|
+
id: \`project-\${node.id}\`,
|
|
547
|
+
source: 'project',
|
|
548
|
+
target: node.id,
|
|
549
|
+
label: node.confidence === 'high' ? 'uses' : 'may use',
|
|
550
|
+
})),
|
|
551
|
+
}), [nodes]);
|
|
552
|
+
|
|
553
|
+
const activeIds = useMemo(() => {
|
|
554
|
+
const ids = new Set<string>();
|
|
555
|
+
if (!selectedId) return ids;
|
|
556
|
+
ids.add(selectedId);
|
|
557
|
+
graphData.links.forEach((link) => {
|
|
558
|
+
const source = endpointId(link.source);
|
|
559
|
+
const target = endpointId(link.target);
|
|
560
|
+
if (source === selectedId) ids.add(target);
|
|
561
|
+
if (target === selectedId) ids.add(source);
|
|
562
|
+
});
|
|
563
|
+
return ids;
|
|
564
|
+
}, [graphData.links, selectedId]);
|
|
565
|
+
|
|
566
|
+
useEffect(() => {
|
|
567
|
+
const element = containerRef.current;
|
|
568
|
+
if (!element) return;
|
|
569
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
570
|
+
setSize({ width: Math.max(1, entry.contentRect.width), height: Math.max(1, entry.contentRect.height) });
|
|
571
|
+
});
|
|
572
|
+
observer.observe(element);
|
|
573
|
+
return () => observer.disconnect();
|
|
574
|
+
}, []);
|
|
575
|
+
|
|
576
|
+
useEffect(() => {
|
|
577
|
+
const graphInstance = graphRef.current;
|
|
578
|
+
if (!graphInstance) return;
|
|
579
|
+
const charge = graphInstance.d3Force('charge') as AdjustableForce | undefined;
|
|
580
|
+
const link = graphInstance.d3Force('link') as AdjustableForce | undefined;
|
|
581
|
+
graphInstance.renderer().setPixelRatio(Math.min(window.devicePixelRatio * 1.5, 3));
|
|
582
|
+
charge?.strength?.(-230);
|
|
583
|
+
link?.distance?.(92);
|
|
584
|
+
graphInstance.d3ReheatSimulation();
|
|
585
|
+
hasFramed.current = false;
|
|
586
|
+
}, [graphData.nodes.length, graphData.links.length]);
|
|
587
|
+
|
|
588
|
+
const focusNode = useCallback((node: NodeObject) => {
|
|
589
|
+
const serviceNode = graphNode(node);
|
|
590
|
+
onSelect(serviceNode.id);
|
|
591
|
+
if (serviceNode.x === undefined || serviceNode.y === undefined || serviceNode.z === undefined) return;
|
|
592
|
+
const distance = Math.hypot(serviceNode.x, serviceNode.y, serviceNode.z) || 1;
|
|
593
|
+
const ratio = 1 + 105 / distance;
|
|
594
|
+
graphRef.current?.cameraPosition(
|
|
595
|
+
{ x: serviceNode.x * ratio, y: serviceNode.y * ratio, z: serviceNode.z * ratio },
|
|
596
|
+
{ x: serviceNode.x, y: serviceNode.y, z: serviceNode.z },
|
|
597
|
+
850,
|
|
598
|
+
);
|
|
599
|
+
}, [onSelect]);
|
|
600
|
+
|
|
601
|
+
const zoom = useCallback((factor: number) => {
|
|
602
|
+
const graphInstance = graphRef.current;
|
|
603
|
+
if (!graphInstance) return;
|
|
604
|
+
const camera = graphInstance.camera();
|
|
605
|
+
const controls = graphInstance.controls() as OrbitControlsLike;
|
|
606
|
+
const target = controls.target?.clone() ?? new THREE.Vector3();
|
|
607
|
+
const offset = camera.position.clone().sub(target);
|
|
608
|
+
const currentDistance = offset.length();
|
|
609
|
+
if (currentDistance === 0) return;
|
|
610
|
+
const nextDistance = THREE.MathUtils.clamp(currentDistance * factor, 35, 1200);
|
|
611
|
+
const nextPosition = target.clone().add(offset.multiplyScalar(nextDistance / currentDistance));
|
|
612
|
+
graphInstance.cameraPosition(nextPosition, target, 240);
|
|
613
|
+
}, []);
|
|
614
|
+
|
|
615
|
+
return (
|
|
616
|
+
<div className="graph-3d" ref={containerRef}>
|
|
617
|
+
<ForceGraph3D
|
|
618
|
+
ref={graphRef as never}
|
|
619
|
+
width={size.width}
|
|
620
|
+
height={size.height}
|
|
621
|
+
graphData={graphData}
|
|
622
|
+
backgroundColor="rgba(0,0,0,0)"
|
|
623
|
+
showNavInfo={false}
|
|
624
|
+
nodeThreeObject={(node) => {
|
|
625
|
+
const serviceNode = graphNode(node);
|
|
626
|
+
return iconObject(serviceNode, serviceNode.id === selectedId);
|
|
627
|
+
}}
|
|
628
|
+
nodeLabel={(node) => {
|
|
629
|
+
const serviceNode = graphNode(node);
|
|
630
|
+
return \`\${serviceNode.name} · \${serviceNode.category}\`;
|
|
631
|
+
}}
|
|
632
|
+
linkLabel={(link) => graphLink(link as GraphLink3D).label}
|
|
633
|
+
linkColor={(link) => {
|
|
634
|
+
const serviceLink = graphLink(link as GraphLink3D);
|
|
635
|
+
if (!selectedId) return '#415149';
|
|
636
|
+
return endpointId(serviceLink.source) === selectedId || endpointId(serviceLink.target) === selectedId ? '#7ee2b8' : '#17211c';
|
|
637
|
+
}}
|
|
638
|
+
linkWidth={(link) => {
|
|
639
|
+
const serviceLink = graphLink(link as GraphLink3D);
|
|
640
|
+
return endpointId(serviceLink.source) === selectedId || endpointId(serviceLink.target) === selectedId ? 2.2 : selectedId ? 0.35 : 0.8;
|
|
641
|
+
}}
|
|
642
|
+
linkOpacity={0.8}
|
|
643
|
+
linkDirectionalArrowLength={3.5}
|
|
644
|
+
linkDirectionalArrowRelPos={0.8}
|
|
645
|
+
linkDirectionalArrowColor={(link) => {
|
|
646
|
+
const serviceLink = graphLink(link as GraphLink3D);
|
|
647
|
+
return endpointId(serviceLink.source) === selectedId || endpointId(serviceLink.target) === selectedId ? '#9ff0cb' : '#506158';
|
|
648
|
+
}}
|
|
649
|
+
linkDirectionalParticles={(link) => {
|
|
650
|
+
const serviceLink = graphLink(link as GraphLink3D);
|
|
651
|
+
return endpointId(serviceLink.source) === selectedId || endpointId(serviceLink.target) === selectedId ? 3 : 0;
|
|
652
|
+
}}
|
|
653
|
+
linkDirectionalParticleWidth={1.8}
|
|
654
|
+
linkDirectionalParticleSpeed={0.006}
|
|
655
|
+
linkDirectionalParticleColor={() => '#b9f6da'}
|
|
656
|
+
d3AlphaDecay={0.035}
|
|
657
|
+
d3VelocityDecay={0.24}
|
|
658
|
+
warmupTicks={50}
|
|
659
|
+
cooldownTicks={160}
|
|
660
|
+
onNodeClick={focusNode}
|
|
661
|
+
onBackgroundClick={() => onSelect(null)}
|
|
662
|
+
onEngineStop={() => {
|
|
663
|
+
if (hasFramed.current) return;
|
|
664
|
+
hasFramed.current = true;
|
|
665
|
+
graphRef.current?.zoomToFit(700, 72);
|
|
666
|
+
}}
|
|
667
|
+
/>
|
|
668
|
+
<div className="graph-3d__zoom" aria-label="3D zoom controls">
|
|
669
|
+
<button type="button" onClick={() => zoom(0.78)} title="Zoom in" aria-label="Zoom in">+</button>
|
|
670
|
+
<button type="button" onClick={() => zoom(1.28)} title="Zoom out" aria-label="Zoom out">−</button>
|
|
671
|
+
</div>
|
|
672
|
+
<div className="graph-3d__help" aria-hidden="true">Drag to rotate <span /> Scroll to zoom <span /> Click a service</div>
|
|
673
|
+
{selectedId && <div className="graph-3d__focus" aria-hidden="true">{Math.max(activeIds.size - 1, 0)} direct connections highlighted</div>}
|
|
674
|
+
</div>
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
|
|
471
678
|
export default function InternalServiceMapPage() {
|
|
472
679
|
const nodes = useMemo<ServiceNode[]>(() => (graph.services ?? []).slice(0, layout.length).map((service, index) => ({
|
|
473
680
|
...service,
|
|
@@ -475,7 +682,7 @@ export default function InternalServiceMapPage() {
|
|
|
475
682
|
color: palette[index % palette.length],
|
|
476
683
|
})), []);
|
|
477
684
|
const [mode, setMode] = useState<'2d' | '3d'>('2d');
|
|
478
|
-
const [
|
|
685
|
+
const [zoom, setZoom] = useState(1);
|
|
479
686
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
480
687
|
const [drafts, setDrafts] = useState<Record<string, DraftDetails>>(() => Object.fromEntries(nodes.map((service) => [
|
|
481
688
|
service.id,
|
|
@@ -487,6 +694,9 @@ export default function InternalServiceMapPage() {
|
|
|
487
694
|
},
|
|
488
695
|
])));
|
|
489
696
|
const selected = nodes.find((service) => service.id === selectedId) ?? null;
|
|
697
|
+
const zoomIn = () => setZoom((current) => Math.min(1.45, Number((current + 0.12).toFixed(2))));
|
|
698
|
+
const zoomOut = () => setZoom((current) => Math.max(0.7, Number((current - 0.12).toFixed(2))));
|
|
699
|
+
const resetZoom = () => setZoom(1);
|
|
490
700
|
|
|
491
701
|
return (
|
|
492
702
|
<main className="servicemap-shell">
|
|
@@ -515,38 +725,40 @@ export default function InternalServiceMapPage() {
|
|
|
515
725
|
</div>
|
|
516
726
|
{mode === '2d' ? (
|
|
517
727
|
<>
|
|
518
|
-
<
|
|
728
|
+
<div className="map-plane" style={{ '--zoom': zoom } as CSSProperties}>
|
|
729
|
+
<svg className="edges" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
|
730
|
+
{nodes.map((service) => (
|
|
731
|
+
<path
|
|
732
|
+
key={service.id}
|
|
733
|
+
className={\`edge-line \${selectedId === service.id ? 'is-active' : selectedId ? 'is-muted' : ''}\`}
|
|
734
|
+
d={edgePath(service)}
|
|
735
|
+
fill="none"
|
|
736
|
+
stroke={selectedId === service.id ? '#65f0ad' : 'rgba(103, 130, 118, 0.48)'}
|
|
737
|
+
strokeWidth={selectedId === service.id ? '0.28' : '0.16'}
|
|
738
|
+
strokeDasharray={selectedId === service.id ? '0.72 0.58' : undefined}
|
|
739
|
+
strokeLinecap={selectedId === service.id ? 'round' : undefined}
|
|
740
|
+
vectorEffect="non-scaling-stroke"
|
|
741
|
+
/>
|
|
742
|
+
))}
|
|
743
|
+
</svg>
|
|
744
|
+
<div className="hub"><div className="hub-inner"><ProjectMark /></div></div>
|
|
519
745
|
{nodes.map((service) => (
|
|
520
|
-
<
|
|
746
|
+
<button
|
|
747
|
+
type="button"
|
|
521
748
|
key={service.id}
|
|
522
|
-
className={\`
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
749
|
+
className={\`service-node \${selectedId === service.id ? 'is-selected' : ''}\`}
|
|
750
|
+
style={{ left: \`\${service.x}%\`, top: \`\${service.y}%\` }}
|
|
751
|
+
onClick={() => setSelectedId(service.id)}
|
|
752
|
+
>
|
|
753
|
+
<ServiceLogo service={service} />
|
|
754
|
+
<div className="service-copy">
|
|
755
|
+
<strong>{service.name}</strong>
|
|
756
|
+
<span>{categoryLabel(service.category)}</span>
|
|
757
|
+
</div>
|
|
758
|
+
<i className={\`status-dot status-\${service.confidence}\`} />
|
|
759
|
+
</button>
|
|
531
760
|
))}
|
|
532
|
-
</
|
|
533
|
-
<div className="hub"><div className="hub-inner"><ProjectMark /></div></div>
|
|
534
|
-
{nodes.map((service) => (
|
|
535
|
-
<button
|
|
536
|
-
type="button"
|
|
537
|
-
key={service.id}
|
|
538
|
-
className={\`service-node \${selectedId === service.id ? 'is-selected' : ''}\`}
|
|
539
|
-
style={{ left: \`\${service.x}%\`, top: \`\${service.y}%\` }}
|
|
540
|
-
onClick={() => setSelectedId(service.id)}
|
|
541
|
-
>
|
|
542
|
-
<ServiceLogo service={service} />
|
|
543
|
-
<div className="service-copy">
|
|
544
|
-
<strong>{service.name}</strong>
|
|
545
|
-
<span>{categoryLabel(service.category)}</span>
|
|
546
|
-
</div>
|
|
547
|
-
<i className={\`status-dot status-\${service.confidence}\`} />
|
|
548
|
-
</button>
|
|
549
|
-
))}
|
|
761
|
+
</div>
|
|
550
762
|
<div className="legend">
|
|
551
763
|
<span><i style={{ background: '#4a7cff' }} />Cloud</span>
|
|
552
764
|
<span><i style={{ background: '#8be4b2' }} />Product</span>
|
|
@@ -554,39 +766,13 @@ export default function InternalServiceMapPage() {
|
|
|
554
766
|
</div>
|
|
555
767
|
</>
|
|
556
768
|
) : (
|
|
557
|
-
<
|
|
558
|
-
<div className="three-space" style={{ '--rx': \`\${rotation.x}deg\`, '--rz': \`\${rotation.z}deg\` } as CSSProperties}>
|
|
559
|
-
{nodes.map((service, index) => (
|
|
560
|
-
<span
|
|
561
|
-
key={\`\${service.id}-link\`}
|
|
562
|
-
className={\`three-link \${selectedId === service.id ? 'is-active' : ''}\`}
|
|
563
|
-
style={threeLinkStyle(index)}
|
|
564
|
-
/>
|
|
565
|
-
))}
|
|
566
|
-
<div className="three-core"><ProjectMark /></div>
|
|
567
|
-
{nodes.map((service, index) => (
|
|
568
|
-
<button
|
|
569
|
-
type="button"
|
|
570
|
-
key={service.id}
|
|
571
|
-
className={\`three-node \${selectedId === service.id ? 'is-selected' : ''}\`}
|
|
572
|
-
style={threeNodeStyle(index, service.color)}
|
|
573
|
-
onPointerMove={(event) => {
|
|
574
|
-
if (event.buttons !== 1) return;
|
|
575
|
-
setRotation((current) => ({
|
|
576
|
-
x: Math.max(-8, Math.min(34, current.x - event.movementY * 0.18)),
|
|
577
|
-
z: current.z + event.movementX * 0.16,
|
|
578
|
-
}));
|
|
579
|
-
}}
|
|
580
|
-
onClick={() => setSelectedId(service.id)}
|
|
581
|
-
>
|
|
582
|
-
<span className="three-orb">{initials(service.name)}</span>
|
|
583
|
-
<span>{service.name}</span>
|
|
584
|
-
</button>
|
|
585
|
-
))}
|
|
586
|
-
</div>
|
|
587
|
-
<div className="three-hint">Drag a service to rotate / Click for details</div>
|
|
588
|
-
</div>
|
|
769
|
+
<ServiceGraph3D nodes={nodes} selectedId={selectedId} onSelect={setSelectedId} />
|
|
589
770
|
)}
|
|
771
|
+
<div className="zoom-controls" aria-label="Map zoom controls">
|
|
772
|
+
<button type="button" className="zoom-button" onClick={zoomIn} aria-label="Zoom in">+</button>
|
|
773
|
+
<button type="button" className="zoom-button" onClick={zoomOut} aria-label="Zoom out">−</button>
|
|
774
|
+
<button type="button" className="zoom-button" onClick={resetZoom} aria-label="Reset zoom"><span className="fit-icon" aria-hidden="true" /></button>
|
|
775
|
+
</div>
|
|
590
776
|
<footer className="scan-foot">
|
|
591
777
|
<div>Generated {new Date(graph.generatedAt).toLocaleString()}</div>
|
|
592
778
|
<div>{nodes.length} services detected</div>
|
|
@@ -675,6 +861,54 @@ async function prepareProjectLogo(graph, outputRoot) {
|
|
|
675
861
|
graph.project.logoUrl = `/internalservicemap-assets/project-logo${extension}`;
|
|
676
862
|
}
|
|
677
863
|
|
|
864
|
+
function run(command, args, options = {}) {
|
|
865
|
+
return new Promise((resolvePromise, reject) => {
|
|
866
|
+
const child = spawn(command, args, { stdio: 'inherit', ...options });
|
|
867
|
+
child.on('error', reject);
|
|
868
|
+
child.on('exit', (code) => {
|
|
869
|
+
if (code === 0) resolvePromise();
|
|
870
|
+
else reject(new Error(`${command} ${args.join(' ')} exited with code ${code}`));
|
|
871
|
+
});
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
async function ensureInteractivePageDependencies(root, routeKind) {
|
|
876
|
+
if (routeKind === 'static' || routeKind === 'astro') return;
|
|
877
|
+
const packageJsonPath = join(root, 'package.json');
|
|
878
|
+
const packageJson = await readJsonOrNull(packageJsonPath);
|
|
879
|
+
if (!packageJson) return;
|
|
880
|
+
|
|
881
|
+
const dependencies = {
|
|
882
|
+
'react-force-graph-3d': '^1.29.1',
|
|
883
|
+
three: '^0.185.1',
|
|
884
|
+
'three-spritetext': '^1.10.0',
|
|
885
|
+
};
|
|
886
|
+
const devDependencies = {
|
|
887
|
+
'@types/three': '^0.185.4',
|
|
888
|
+
};
|
|
889
|
+
let changed = false;
|
|
890
|
+
|
|
891
|
+
packageJson.dependencies ??= {};
|
|
892
|
+
for (const [name, version] of Object.entries(dependencies)) {
|
|
893
|
+
if (packageJson.dependencies[name] || packageJson.devDependencies?.[name]) continue;
|
|
894
|
+
packageJson.dependencies[name] = version;
|
|
895
|
+
changed = true;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
packageJson.devDependencies ??= {};
|
|
899
|
+
for (const [name, version] of Object.entries(devDependencies)) {
|
|
900
|
+
if (packageJson.dependencies?.[name] || packageJson.devDependencies[name]) continue;
|
|
901
|
+
packageJson.devDependencies[name] = version;
|
|
902
|
+
changed = true;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
if (!changed) return;
|
|
906
|
+
await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
|
907
|
+
if (existsSync(join(root, 'package-lock.json'))) {
|
|
908
|
+
await run('npm', ['install'], { cwd: root });
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
678
912
|
async function main() {
|
|
679
913
|
const options = parseArgs();
|
|
680
914
|
const graph = await readJson(options.input);
|
|
@@ -684,6 +918,7 @@ async function main() {
|
|
|
684
918
|
|
|
685
919
|
await mkdir(dirname(route.path), { recursive: true });
|
|
686
920
|
await writeFile(route.path, contents);
|
|
921
|
+
await ensureInteractivePageDependencies(options.root, route.kind);
|
|
687
922
|
console.log(`Wrote /internalservicemap page to ${route.path}`);
|
|
688
923
|
}
|
|
689
924
|
|
|
@@ -194,6 +194,36 @@ function buildGeneratedRules(database) {
|
|
|
194
194
|
}));
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
+
function normalizeLookup(value) {
|
|
198
|
+
return String(value ?? '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function buildDatabaseLookup(database) {
|
|
202
|
+
const lookup = new Map();
|
|
203
|
+
for (const entry of database) {
|
|
204
|
+
const values = [entry.key, entry.name, ...(entry.aliases ?? [])];
|
|
205
|
+
for (const value of values) {
|
|
206
|
+
const normalized = normalizeLookup(value);
|
|
207
|
+
if (normalized && !lookup.has(normalized)) lookup.set(normalized, entry);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return lookup;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function enrichDetectedServices(services, database) {
|
|
214
|
+
const lookup = buildDatabaseLookup(database);
|
|
215
|
+
return services.map((service) => {
|
|
216
|
+
const databaseEntry = lookup.get(normalizeLookup(service.key)) ?? lookup.get(normalizeLookup(service.name));
|
|
217
|
+
if (!databaseEntry) return service;
|
|
218
|
+
return {
|
|
219
|
+
...service,
|
|
220
|
+
key: service.key ?? databaseEntry.key,
|
|
221
|
+
category: service.category ?? databaseEntry.category,
|
|
222
|
+
iconUrl: service.iconUrl ?? databaseEntry.iconUrl ?? null,
|
|
223
|
+
};
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
197
227
|
function matchServices({ dependencies, imports, envNames, hosts, configFiles, database }) {
|
|
198
228
|
const serviceMap = new Map();
|
|
199
229
|
const databaseByKey = new Map(database.map((entry) => [entry.key, entry]));
|
|
@@ -226,7 +256,7 @@ function matchServices({ dependencies, imports, envNames, hosts, configFiles, da
|
|
|
226
256
|
if (configFiles.includes('vercel.json')) addEvidence(serviceMap, 'vercel', enrichedManualRules.find((rule) => rule.key === 'vercel'), { type: 'config-file', value: 'vercel.json', confidence: 'high' });
|
|
227
257
|
if (configFiles.includes('wrangler.toml')) addEvidence(serviceMap, 'cloudflare', enrichedManualRules.find((rule) => rule.key === 'cloudflare'), { type: 'config-file', value: 'wrangler.toml', confidence: 'high' });
|
|
228
258
|
|
|
229
|
-
return [...serviceMap.values()].map((service) => ({
|
|
259
|
+
return enrichDetectedServices([...serviceMap.values()], database).map((service) => ({
|
|
230
260
|
...service,
|
|
231
261
|
evidence: service.evidence.slice(0, 8),
|
|
232
262
|
})).sort((a, b) => a.name.localeCompare(b.name));
|