servicemap 0.1.31 → 0.1.38
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';
|
|
@@ -60,22 +69,29 @@ function pageCss() {
|
|
|
60
69
|
.brand h1 { margin: 0; font-size: 20px; line-height: 1; letter-spacing: 0; }
|
|
61
70
|
.brand p, .top-meta { margin: 6px 0 0; color: #8aa097; font-size: 14px; }
|
|
62
71
|
.top-actions { display: flex; align-items: center; gap: 12px; color: #8da59a; font-size: 14px; white-space: nowrap; }
|
|
72
|
+
.add-service-button { min-height: 40px; border: 0; border-radius: 10px; background: #287a5a; color: #effbf5; padding: 0 14px; cursor: pointer; font: inherit; font-weight: 800; box-shadow: 0 10px 28px rgba(54,180,126,0.16); }
|
|
63
73
|
.mode-toggle { display: inline-flex; gap: 4px; padding: 4px; border-radius: 10px; background: #0b130f; }
|
|
64
74
|
.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
75
|
.mode-button.is-active { background: #276f53; color: #c9f9df; box-shadow: 0 0 18px rgba(101,240,173,0.16); }
|
|
66
76
|
.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; }
|
|
77
|
+
.map.is-panning { cursor: grabbing; user-select: none; }
|
|
78
|
+
.map-plane { position: absolute; inset: 0; z-index: 20; transform: translate3d(var(--pan-x, 0px), var(--pan-y, 0px), 0) scale(var(--zoom, 1)); transform-origin: 50% 50%; transition: transform 160ms ease; }
|
|
79
|
+
.map.is-panning .map-plane { transition: none; }
|
|
67
80
|
.intro { position: absolute; left: 32px; top: 32px; z-index: 20; }
|
|
68
81
|
.intro strong { display: block; color: #779286; font-size: 12px; letter-spacing: 0.22em; text-transform: uppercase; }
|
|
69
82
|
.intro span { display: block; margin-top: 8px; color: #53665d; font-size: 14px; }
|
|
70
83
|
.edges { position: absolute; inset: 0; z-index: 26; width: 100%; height: 100%; pointer-events: none; }
|
|
71
84
|
.edge-line { transition: opacity 180ms ease, stroke 180ms ease; }
|
|
72
|
-
.edge-line.is-active { stroke: #65f0ad !important; stroke-width:
|
|
73
|
-
.edge-line.is-muted { opacity: 0.
|
|
85
|
+
.edge-line.is-active { stroke: #65f0ad !important; stroke-width: 3px; stroke-linecap: round; stroke-dasharray: 10 8; animation: servicemapDash .74s linear infinite; filter: drop-shadow(0 0 9px rgba(101,240,173,0.9)) drop-shadow(0 0 18px rgba(101,240,173,0.45)); opacity: 1; }
|
|
86
|
+
.edge-line.is-muted { opacity: 0.12; }
|
|
74
87
|
.hub { position: absolute; left: 50%; top: 50%; z-index: 20; width: 132px; height: 132px; transform: translate(-50%, -50%); border-radius: 24px; border: 1px solid #315c47; background: #09120d; display: grid; place-items: center; box-shadow: 0 0 0 9px rgba(79,183,128,0.12), 0 0 42px rgba(100,255,174,0.16); }
|
|
75
88
|
.hub-inner { width: 92px; height: 92px; border-radius: 18px; background: #fbfbf7; color: #111612; display: grid; place-items: center; font-size: 40px; box-shadow: inset 0 0 0 1px rgba(0,0,0,0.08); }
|
|
76
89
|
.service-node { position: absolute; z-index: 10; width: 250px; height: 74px; transform: translate(-50%, -50%); display: flex; align-items: center; gap: 16px; padding: 0 16px; border-radius: 8px; border: 1px solid #274036; background: rgba(16,24,19,0.95); box-shadow: 0 0 0 1px rgba(140,255,190,0.05), 0 18px 44px rgba(0,0,0,0.38); }
|
|
77
90
|
button.service-node { cursor: pointer; color: inherit; font: inherit; text-align: left; }
|
|
78
91
|
.service-node:hover, .service-node.is-selected { border-color: #66efae; box-shadow: 0 0 0 2px rgba(101,240,173,0.42), 0 18px 44px rgba(0,0,0,0.38); }
|
|
92
|
+
.map.has-selection .service-node { opacity: 0.16; }
|
|
93
|
+
.map.has-selection .service-node.is-related { opacity: 0.48; }
|
|
94
|
+
.map.has-selection .service-node.is-selected { opacity: 1; }
|
|
79
95
|
.service-node.is-selected { z-index: 27; }
|
|
80
96
|
.logo { width: 48px; height: 48px; flex: 0 0 auto; border-radius: 8px; display: grid; place-items: center; overflow: hidden; color: #fff; font-weight: 700; }
|
|
81
97
|
.logo img { width: 28px; height: 28px; object-fit: contain; filter: invert(1); }
|
|
@@ -84,12 +100,17 @@ function pageCss() {
|
|
|
84
100
|
.service-copy span { display: block; margin-top: 5px; color: #7f9188; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
85
101
|
.status-dot { margin-left: auto; width: 8px; height: 8px; flex: 0 0 auto; border-radius: 999px; box-shadow: 0 0 14px currentColor; }
|
|
86
102
|
.status-high { color: #65f0ad; background: #65f0ad; }
|
|
87
|
-
.status-medium, .status-low { color: #f1c85b; background: #f1c85b; }
|
|
103
|
+
.status-medium, .status-low, .status-manual { color: #f1c85b; background: #f1c85b; }
|
|
88
104
|
.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
105
|
.legend i { display: inline-block; width: 8px; height: 8px; margin-right: 8px; border-radius: 999px; }
|
|
106
|
+
.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); }
|
|
107
|
+
.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; }
|
|
108
|
+
.zoom-button:last-child { border-bottom: 0; font-size: 18px; }
|
|
109
|
+
.zoom-button:hover { background: rgba(101,240,173,0.1); color: #d8f8e8; }
|
|
110
|
+
.fit-icon { display: inline-block; width: 15px; height: 15px; border: 2px solid currentColor; border-radius: 3px; }
|
|
90
111
|
.scan-foot { position: absolute; right: 32px; bottom: 32px; z-index: 20; color: #53665d; text-align: right; font-size: 12px; line-height: 1.6; }
|
|
91
112
|
.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
|
-
.drawer-scrim { position: absolute; inset: 0; z-index:
|
|
113
|
+
.drawer-scrim { position: absolute; inset: 0; z-index: 18; background: rgba(0,0,0,0.58); pointer-events: none; }
|
|
93
114
|
.details-panel { position: absolute; right: 22px; top: 64px; bottom: 26px; z-index: 30; width: min(492px, calc(100vw - 44px)); overflow: hidden; border: 1px solid #294034; border-radius: 24px; background: linear-gradient(180deg, rgba(21,31,25,0.98), rgba(8,17,12,0.98)); box-shadow: 0 24px 80px rgba(0,0,0,0.52); }
|
|
94
115
|
.details-head { display: flex; align-items: center; gap: 16px; padding: 26px 26px 22px; border-bottom: 1px solid rgba(255,255,255,0.08); }
|
|
95
116
|
.details-title { min-width: 0; flex: 1; }
|
|
@@ -114,17 +135,25 @@ function pageCss() {
|
|
|
114
135
|
.delete-button { color: #ff6b57; background: transparent; font-weight: 800; }
|
|
115
136
|
.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
137
|
.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
|
-
.
|
|
138
|
+
.modal-card { position: absolute; left: 50%; top: 50%; z-index: 31; width: min(440px, calc(100vw - 44px)); transform: translate(-50%, -50%); border: 1px solid #294034; border-radius: 20px; background: linear-gradient(180deg, rgba(21,31,25,0.99), rgba(8,17,12,0.99)); box-shadow: 0 24px 80px rgba(0,0,0,0.56); overflow: hidden; }
|
|
139
|
+
.modal-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 22px 24px; border-bottom: 1px solid rgba(255,255,255,0.08); }
|
|
140
|
+
.modal-head h2 { margin: 0; color: #f4fbf6; font-size: 20px; letter-spacing: 0; }
|
|
141
|
+
.modal-body { padding: 22px 24px 24px; }
|
|
142
|
+
.modal-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 22px; }
|
|
143
|
+
.secondary-button { min-height: 44px; border: 1px solid #2a3c33; border-radius: 10px; background: rgba(255,255,255,0.04); color: #c8d8d0; padding: 0 16px; cursor: pointer; font: inherit; font-weight: 800; }
|
|
144
|
+
.graph-3d { position: absolute; inset: 0; overflow: hidden; }
|
|
145
|
+
.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%); }
|
|
146
|
+
.graph-3d canvas { display: block; cursor: grab; }
|
|
147
|
+
.graph-3d canvas:active { cursor: grabbing; }
|
|
148
|
+
.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; }
|
|
149
|
+
.graph-3d__help { left: 50%; transform: translateX(-50%); display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 999px; white-space: nowrap; }
|
|
150
|
+
.graph-3d__help span { width: 3px; height: 3px; border-radius: 50%; background: #4a5a52; }
|
|
151
|
+
.graph-3d__focus { left: 22px; padding: 8px 10px; border-radius: 8px; color: #91bba7; }
|
|
152
|
+
.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); }
|
|
153
|
+
.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; }
|
|
154
|
+
.graph-3d__zoom button + button { border-top: 1px solid rgba(255,255,255,.08); }
|
|
155
|
+
.graph-3d__zoom button:hover { color: #fff; background: rgba(255,255,255,.08); }
|
|
156
|
+
.graph-3d__loading { position: absolute; inset: 0; display: grid; place-items: center; color: #75857c; font-size: 9px; }
|
|
128
157
|
@keyframes servicemapDash { to { stroke-dashoffset: -1.3; } }
|
|
129
158
|
@keyframes servicemapLinkFlow { to { background-position: 22px 0; } }
|
|
130
159
|
@media (max-width: 920px) {
|
|
@@ -245,18 +274,28 @@ ${htmlBody(graph)}
|
|
|
245
274
|
function interactiveTsxPage(graph) {
|
|
246
275
|
return `'use client';
|
|
247
276
|
|
|
248
|
-
import
|
|
277
|
+
import dynamic from 'next/dynamic';
|
|
278
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent } from 'react';
|
|
279
|
+
import type { ForceGraphMethods, NodeObject } from 'react-force-graph-3d';
|
|
280
|
+
import * as THREE from 'three';
|
|
281
|
+
import SpriteText from 'three-spritetext';
|
|
249
282
|
|
|
250
283
|
const css = ${JSON.stringify(pageCss())};
|
|
251
284
|
type Evidence = { type?: string; value?: string; confidence?: string };
|
|
252
|
-
type RawService = { id: string; name: string; key?: string; category?: string; confidence?: string; evidence?: Evidence[]; iconUrl?: string | null };
|
|
285
|
+
type RawService = { id: string; name: string; key?: string; category?: string; confidence?: string; evidence?: Evidence[]; iconUrl?: string | null; parentId?: string | null };
|
|
253
286
|
type ServiceNode = RawService & { color: string; x: number; y: number };
|
|
254
287
|
type DraftDetails = { subtitle: string; use: string; account: string; passwordLocation: string };
|
|
288
|
+
type NewServiceDraft = { name: string; category: string; parentId: string };
|
|
255
289
|
type ServiceLink = { label: string; url: string };
|
|
290
|
+
type GraphNode3D = { id: string; name: string; category: string; color: string; iconUrl?: string | null; monogram: string; isProduct: boolean; x?: number; y?: number; z?: number };
|
|
291
|
+
type GraphLink3D = { id: string; source: string | GraphNode3D; target: string | GraphNode3D; label: string };
|
|
292
|
+
type AdjustableForce = { strength?: (value: number) => unknown; distance?: (value: number) => unknown };
|
|
293
|
+
type OrbitControlsLike = { target?: THREE.Vector3 };
|
|
256
294
|
|
|
257
295
|
const graph = ${JSON.stringify(graph)} as { generatedAt: string; project?: { name?: string; logoUrl?: string }; services?: RawService[] };
|
|
258
296
|
const palette = ${JSON.stringify(palette)} as string[];
|
|
259
297
|
const layout = ${JSON.stringify(layout)} as Array<{ x: number; y: number }>;
|
|
298
|
+
const ForceGraph3D = dynamic(() => import('react-force-graph-3d'), { ssr: false });
|
|
260
299
|
|
|
261
300
|
function categoryLabel(value?: string | null) {
|
|
262
301
|
return String(value ?? 'service').replace(/-/g, ' ');
|
|
@@ -267,75 +306,116 @@ function initials(name: string) {
|
|
|
267
306
|
}
|
|
268
307
|
|
|
269
308
|
function edgePath(node: ServiceNode) {
|
|
309
|
+
return edgePathBetween({ x: 50, y: 50 }, node);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function edgePathBetween(parent: { x: number; y: number }, node: ServiceNode) {
|
|
270
313
|
const isLeft = node.x < 50;
|
|
271
|
-
const fromX =
|
|
272
|
-
const
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
function threePosition(index: number) {
|
|
278
|
-
const constellation = [
|
|
279
|
-
{ x: 0, y: -240, z: 46 },
|
|
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
|
-
};
|
|
314
|
+
const fromX = parent.x;
|
|
315
|
+
const fromY = parent.y;
|
|
316
|
+
const endX = isLeft ? node.x + 8 : node.x - 8;
|
|
317
|
+
const midX = (fromX + endX) / 2;
|
|
318
|
+
return \`M \${fromX} \${fromY} H \${midX} V \${node.y} H \${endX}\`;
|
|
314
319
|
}
|
|
315
320
|
|
|
316
|
-
function
|
|
317
|
-
const
|
|
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;
|
|
321
|
+
function primaryEvidence(service: RawService) {
|
|
322
|
+
const evidence = service.evidence?.[0];
|
|
323
|
+
return evidence ? \`\${evidence.type}: \${evidence.value}\` : 'No evidence captured yet.';
|
|
325
324
|
}
|
|
326
325
|
|
|
327
|
-
function
|
|
328
|
-
|
|
326
|
+
function endpointId(endpoint: string | number | GraphNode3D) {
|
|
327
|
+
return typeof endpoint === 'object' ? endpoint.id : String(endpoint);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function graphNode(node: NodeObject) {
|
|
331
|
+
return node as GraphNode3D;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function graphLink(link: GraphLink3D) {
|
|
335
|
+
return link;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function slugify(value: string) {
|
|
339
|
+
return value.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'service';
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function servicePosition(index: number) {
|
|
343
|
+
const point = layout[index];
|
|
344
|
+
if (point) return point;
|
|
345
|
+
const angle = (index - layout.length) * 2.399963229728653;
|
|
346
|
+
const radius = 34 + ((index - layout.length) % 4) * 4;
|
|
329
347
|
return {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
} as CSSProperties;
|
|
348
|
+
x: 50 + Math.cos(angle) * radius,
|
|
349
|
+
y: 50 + Math.sin(angle) * radius * 0.72,
|
|
350
|
+
};
|
|
334
351
|
}
|
|
335
352
|
|
|
336
|
-
function
|
|
337
|
-
|
|
338
|
-
|
|
353
|
+
function relationshipLabel(parentId?: string | null) {
|
|
354
|
+
return parentId ? 'depends on' : 'uses';
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function iconObject(node: GraphNode3D, selected: boolean) {
|
|
358
|
+
const group = new THREE.Group();
|
|
359
|
+
const radius = node.isProduct ? 10 : 7;
|
|
360
|
+
const shell = new THREE.Mesh(
|
|
361
|
+
new THREE.SphereGeometry(radius, 32, 32),
|
|
362
|
+
new THREE.MeshStandardMaterial({
|
|
363
|
+
color: new THREE.Color(node.color),
|
|
364
|
+
emissive: new THREE.Color(node.color),
|
|
365
|
+
emissiveIntensity: selected ? 0.65 : 0.23,
|
|
366
|
+
metalness: 0.18,
|
|
367
|
+
roughness: 0.48,
|
|
368
|
+
}),
|
|
369
|
+
);
|
|
370
|
+
group.add(shell);
|
|
371
|
+
|
|
372
|
+
const halo = new THREE.Mesh(
|
|
373
|
+
new THREE.SphereGeometry(radius + (selected ? 2.2 : 1.25), 24, 24),
|
|
374
|
+
new THREE.MeshBasicMaterial({ color: selected ? '#9ff0cb' : node.color, transparent: true, opacity: selected ? 0.18 : 0.07, side: THREE.BackSide }),
|
|
375
|
+
);
|
|
376
|
+
group.add(halo);
|
|
377
|
+
|
|
378
|
+
const fallback = new SpriteText(node.monogram.slice(0, 3));
|
|
379
|
+
fallback.color = '#ffffff';
|
|
380
|
+
fallback.textHeight = node.isProduct ? 4.2 : 3.2;
|
|
381
|
+
fallback.fontSize = 180;
|
|
382
|
+
fallback.fontWeight = '700';
|
|
383
|
+
fallback.renderOrder = 10;
|
|
384
|
+
fallback.material.depthTest = false;
|
|
385
|
+
fallback.material.depthWrite = false;
|
|
386
|
+
group.add(fallback);
|
|
387
|
+
|
|
388
|
+
if (node.iconUrl) {
|
|
389
|
+
new THREE.TextureLoader().load(node.iconUrl, (texture) => {
|
|
390
|
+
texture.colorSpace = THREE.SRGBColorSpace;
|
|
391
|
+
texture.anisotropy = 16;
|
|
392
|
+
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
|
393
|
+
texture.magFilter = THREE.LinearFilter;
|
|
394
|
+
texture.generateMipmaps = true;
|
|
395
|
+
texture.needsUpdate = true;
|
|
396
|
+
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false, depthWrite: false, alphaTest: 0.015 }));
|
|
397
|
+
const size = node.isProduct ? 12 : 8.5;
|
|
398
|
+
sprite.scale.set(size, size, 1);
|
|
399
|
+
sprite.renderOrder = 10;
|
|
400
|
+
group.add(sprite);
|
|
401
|
+
fallback.visible = false;
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const label = new SpriteText(node.name);
|
|
406
|
+
label.color = selected ? '#b9f6da' : '#dfeae4';
|
|
407
|
+
label.textHeight = selected ? 4.1 : 3.2;
|
|
408
|
+
label.fontSize = 220;
|
|
409
|
+
label.fontWeight = selected ? '700' : '600';
|
|
410
|
+
label.backgroundColor = 'rgba(5, 8, 7, .72)';
|
|
411
|
+
label.padding = 1.5;
|
|
412
|
+
label.borderRadius = 2;
|
|
413
|
+
label.position.y = -(radius + 6.8);
|
|
414
|
+
label.renderOrder = 11;
|
|
415
|
+
label.material.depthTest = false;
|
|
416
|
+
label.material.depthWrite = false;
|
|
417
|
+
group.add(label);
|
|
418
|
+
return group;
|
|
339
419
|
}
|
|
340
420
|
|
|
341
421
|
function serviceLinks(service: RawService): ServiceLink[] {
|
|
@@ -468,15 +548,257 @@ function DetailsPanel({
|
|
|
468
548
|
);
|
|
469
549
|
}
|
|
470
550
|
|
|
551
|
+
function AddServiceModal({
|
|
552
|
+
draft,
|
|
553
|
+
nodes,
|
|
554
|
+
onChange,
|
|
555
|
+
onClose,
|
|
556
|
+
onSubmit,
|
|
557
|
+
}: {
|
|
558
|
+
draft: NewServiceDraft;
|
|
559
|
+
nodes: ServiceNode[];
|
|
560
|
+
onChange: (draft: NewServiceDraft) => void;
|
|
561
|
+
onClose: () => void;
|
|
562
|
+
onSubmit: () => void;
|
|
563
|
+
}) {
|
|
564
|
+
return (
|
|
565
|
+
<>
|
|
566
|
+
<div className="drawer-scrim" />
|
|
567
|
+
<section className="modal-card" aria-label="Add service">
|
|
568
|
+
<div className="modal-head">
|
|
569
|
+
<h2>Add service</h2>
|
|
570
|
+
<button type="button" className="close-button" onClick={onClose} aria-label="Close add service">×</button>
|
|
571
|
+
</div>
|
|
572
|
+
<div className="modal-body">
|
|
573
|
+
<label className="field-label" htmlFor="new-service-name">Service name</label>
|
|
574
|
+
<input
|
|
575
|
+
id="new-service-name"
|
|
576
|
+
className="details-input"
|
|
577
|
+
value={draft.name}
|
|
578
|
+
onChange={(event) => onChange({ ...draft, name: event.target.value })}
|
|
579
|
+
placeholder="Stripe, Vercel, Google Cloud"
|
|
580
|
+
autoFocus
|
|
581
|
+
/>
|
|
582
|
+
|
|
583
|
+
<label className="field-label" htmlFor="new-service-category">Use</label>
|
|
584
|
+
<input
|
|
585
|
+
id="new-service-category"
|
|
586
|
+
className="details-input"
|
|
587
|
+
value={draft.category}
|
|
588
|
+
onChange={(event) => onChange({ ...draft, category: event.target.value })}
|
|
589
|
+
placeholder="billing, hosting, analytics"
|
|
590
|
+
/>
|
|
591
|
+
|
|
592
|
+
<label className="field-label" htmlFor="new-service-parent">Parent node</label>
|
|
593
|
+
<select
|
|
594
|
+
id="new-service-parent"
|
|
595
|
+
className="details-input"
|
|
596
|
+
value={draft.parentId}
|
|
597
|
+
onChange={(event) => onChange({ ...draft, parentId: event.target.value })}
|
|
598
|
+
>
|
|
599
|
+
<option value="product">{graph.project?.name ?? 'Product'}</option>
|
|
600
|
+
{nodes.map((node) => (
|
|
601
|
+
<option key={node.id} value={node.id}>{node.name}</option>
|
|
602
|
+
))}
|
|
603
|
+
</select>
|
|
604
|
+
|
|
605
|
+
<div className="modal-actions">
|
|
606
|
+
<button type="button" className="secondary-button" onClick={onClose}>Cancel</button>
|
|
607
|
+
<button type="button" className="save-button" onClick={onSubmit}>Add service</button>
|
|
608
|
+
</div>
|
|
609
|
+
</div>
|
|
610
|
+
</section>
|
|
611
|
+
</>
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function ServiceGraph3D({
|
|
616
|
+
nodes,
|
|
617
|
+
selectedId,
|
|
618
|
+
onSelect,
|
|
619
|
+
}: {
|
|
620
|
+
nodes: ServiceNode[];
|
|
621
|
+
selectedId: string | null;
|
|
622
|
+
onSelect: (id: string | null) => void;
|
|
623
|
+
}) {
|
|
624
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
625
|
+
const graphRef = useRef<ForceGraphMethods<GraphNode3D, GraphLink3D> | undefined>(undefined);
|
|
626
|
+
const [size, setSize] = useState({ width: 900, height: 700 });
|
|
627
|
+
const hasFramed = useRef(false);
|
|
628
|
+
const hasConfiguredForces = useRef(false);
|
|
629
|
+
const graphData = useMemo(() => ({
|
|
630
|
+
nodes: [{
|
|
631
|
+
id: 'project',
|
|
632
|
+
name: graph.project?.name ?? 'Project',
|
|
633
|
+
category: 'product',
|
|
634
|
+
color: '#8be4b2',
|
|
635
|
+
iconUrl: graph.project?.logoUrl,
|
|
636
|
+
monogram: initials(graph.project?.name ?? 'Project'),
|
|
637
|
+
isProduct: true,
|
|
638
|
+
}, ...nodes.map((node): GraphNode3D => ({
|
|
639
|
+
id: node.id,
|
|
640
|
+
name: node.name,
|
|
641
|
+
category: categoryLabel(node.category),
|
|
642
|
+
color: node.color,
|
|
643
|
+
iconUrl: node.iconUrl,
|
|
644
|
+
monogram: initials(node.name),
|
|
645
|
+
isProduct: node.category === 'product',
|
|
646
|
+
}))],
|
|
647
|
+
links: nodes.map((node): GraphLink3D => ({
|
|
648
|
+
id: \`\${node.parentId ?? 'project'}-\${node.id}\`,
|
|
649
|
+
source: node.parentId ?? 'project',
|
|
650
|
+
target: node.id,
|
|
651
|
+
label: node.confidence === 'high' ? relationshipLabel(node.parentId) : \`may \${relationshipLabel(node.parentId)}\`,
|
|
652
|
+
})),
|
|
653
|
+
}), [nodes]);
|
|
654
|
+
|
|
655
|
+
const activeIds = useMemo(() => {
|
|
656
|
+
const ids = new Set<string>();
|
|
657
|
+
if (!selectedId) return ids;
|
|
658
|
+
ids.add(selectedId);
|
|
659
|
+
graphData.links.forEach((link) => {
|
|
660
|
+
const source = endpointId(link.source);
|
|
661
|
+
const target = endpointId(link.target);
|
|
662
|
+
if (source === selectedId) ids.add(target);
|
|
663
|
+
if (target === selectedId) ids.add(source);
|
|
664
|
+
});
|
|
665
|
+
return ids;
|
|
666
|
+
}, [graphData.links, selectedId]);
|
|
667
|
+
|
|
668
|
+
useEffect(() => {
|
|
669
|
+
const element = containerRef.current;
|
|
670
|
+
if (!element) return;
|
|
671
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
672
|
+
setSize({ width: Math.max(1, entry.contentRect.width), height: Math.max(1, entry.contentRect.height) });
|
|
673
|
+
});
|
|
674
|
+
observer.observe(element);
|
|
675
|
+
return () => observer.disconnect();
|
|
676
|
+
}, []);
|
|
677
|
+
|
|
678
|
+
const configureForces = useCallback(() => {
|
|
679
|
+
const graphInstance = graphRef.current;
|
|
680
|
+
if (!graphInstance || hasConfiguredForces.current) return;
|
|
681
|
+
const charge = graphInstance.d3Force('charge') as AdjustableForce | undefined;
|
|
682
|
+
const link = graphInstance.d3Force('link') as AdjustableForce | undefined;
|
|
683
|
+
graphInstance.renderer().setPixelRatio(Math.min(window.devicePixelRatio * 1.5, 3));
|
|
684
|
+
charge?.strength?.(-720);
|
|
685
|
+
link?.distance?.(210);
|
|
686
|
+
graphInstance.d3ReheatSimulation();
|
|
687
|
+
hasConfiguredForces.current = true;
|
|
688
|
+
}, []);
|
|
689
|
+
|
|
690
|
+
useEffect(() => {
|
|
691
|
+
hasFramed.current = false;
|
|
692
|
+
hasConfiguredForces.current = false;
|
|
693
|
+
const frame = window.requestAnimationFrame(configureForces);
|
|
694
|
+
return () => window.cancelAnimationFrame(frame);
|
|
695
|
+
}, [configureForces, graphData.nodes.length, graphData.links.length]);
|
|
696
|
+
|
|
697
|
+
const focusNode = useCallback((node: NodeObject) => {
|
|
698
|
+
const serviceNode = graphNode(node);
|
|
699
|
+
onSelect(serviceNode.id);
|
|
700
|
+
if (serviceNode.x === undefined || serviceNode.y === undefined || serviceNode.z === undefined) return;
|
|
701
|
+
const distance = Math.hypot(serviceNode.x, serviceNode.y, serviceNode.z) || 1;
|
|
702
|
+
const ratio = 1 + 105 / distance;
|
|
703
|
+
graphRef.current?.cameraPosition(
|
|
704
|
+
{ x: serviceNode.x * ratio, y: serviceNode.y * ratio, z: serviceNode.z * ratio },
|
|
705
|
+
{ x: serviceNode.x, y: serviceNode.y, z: serviceNode.z },
|
|
706
|
+
850,
|
|
707
|
+
);
|
|
708
|
+
}, [onSelect]);
|
|
709
|
+
|
|
710
|
+
const zoom = useCallback((factor: number) => {
|
|
711
|
+
const graphInstance = graphRef.current;
|
|
712
|
+
if (!graphInstance) return;
|
|
713
|
+
const camera = graphInstance.camera();
|
|
714
|
+
const controls = graphInstance.controls() as OrbitControlsLike;
|
|
715
|
+
const target = controls.target?.clone() ?? new THREE.Vector3();
|
|
716
|
+
const offset = camera.position.clone().sub(target);
|
|
717
|
+
const currentDistance = offset.length();
|
|
718
|
+
if (currentDistance === 0) return;
|
|
719
|
+
const nextDistance = THREE.MathUtils.clamp(currentDistance * factor, 35, 1200);
|
|
720
|
+
const nextPosition = target.clone().add(offset.multiplyScalar(nextDistance / currentDistance));
|
|
721
|
+
graphInstance.cameraPosition(nextPosition, target, 240);
|
|
722
|
+
}, []);
|
|
723
|
+
|
|
724
|
+
return (
|
|
725
|
+
<div className="graph-3d" ref={containerRef}>
|
|
726
|
+
<ForceGraph3D
|
|
727
|
+
ref={graphRef as never}
|
|
728
|
+
width={size.width}
|
|
729
|
+
height={size.height}
|
|
730
|
+
graphData={graphData}
|
|
731
|
+
backgroundColor="rgba(0,0,0,0)"
|
|
732
|
+
showNavInfo={false}
|
|
733
|
+
nodeThreeObject={(node) => {
|
|
734
|
+
const serviceNode = graphNode(node);
|
|
735
|
+
return iconObject(serviceNode, serviceNode.id === selectedId);
|
|
736
|
+
}}
|
|
737
|
+
nodeLabel={(node) => {
|
|
738
|
+
const serviceNode = graphNode(node);
|
|
739
|
+
return \`\${serviceNode.name} · \${serviceNode.category}\`;
|
|
740
|
+
}}
|
|
741
|
+
linkLabel={(link) => graphLink(link as GraphLink3D).label}
|
|
742
|
+
linkColor={(link) => {
|
|
743
|
+
const serviceLink = graphLink(link as GraphLink3D);
|
|
744
|
+
if (!selectedId) return '#415149';
|
|
745
|
+
return endpointId(serviceLink.source) === selectedId || endpointId(serviceLink.target) === selectedId ? '#7ee2b8' : '#17211c';
|
|
746
|
+
}}
|
|
747
|
+
linkWidth={(link) => {
|
|
748
|
+
const serviceLink = graphLink(link as GraphLink3D);
|
|
749
|
+
return endpointId(serviceLink.source) === selectedId || endpointId(serviceLink.target) === selectedId ? 2.2 : selectedId ? 0.35 : 0.8;
|
|
750
|
+
}}
|
|
751
|
+
linkOpacity={0.8}
|
|
752
|
+
linkDirectionalArrowLength={3.5}
|
|
753
|
+
linkDirectionalArrowRelPos={0.8}
|
|
754
|
+
linkDirectionalArrowColor={(link) => {
|
|
755
|
+
const serviceLink = graphLink(link as GraphLink3D);
|
|
756
|
+
return endpointId(serviceLink.source) === selectedId || endpointId(serviceLink.target) === selectedId ? '#9ff0cb' : '#506158';
|
|
757
|
+
}}
|
|
758
|
+
linkDirectionalParticles={(link) => {
|
|
759
|
+
const serviceLink = graphLink(link as GraphLink3D);
|
|
760
|
+
return endpointId(serviceLink.source) === selectedId || endpointId(serviceLink.target) === selectedId ? 3 : 0;
|
|
761
|
+
}}
|
|
762
|
+
linkDirectionalParticleWidth={1.8}
|
|
763
|
+
linkDirectionalParticleSpeed={0.006}
|
|
764
|
+
linkDirectionalParticleColor={() => '#b9f6da'}
|
|
765
|
+
d3AlphaDecay={0.026}
|
|
766
|
+
d3VelocityDecay={0.18}
|
|
767
|
+
warmupTicks={90}
|
|
768
|
+
cooldownTicks={260}
|
|
769
|
+
onNodeClick={focusNode}
|
|
770
|
+
onBackgroundClick={() => onSelect(null)}
|
|
771
|
+
onEngineTick={configureForces}
|
|
772
|
+
onEngineStop={() => {
|
|
773
|
+
if (hasFramed.current) return;
|
|
774
|
+
hasFramed.current = true;
|
|
775
|
+
graphRef.current?.zoomToFit(700, 130);
|
|
776
|
+
}}
|
|
777
|
+
/>
|
|
778
|
+
<div className="graph-3d__zoom" aria-label="3D zoom controls">
|
|
779
|
+
<button type="button" onClick={() => zoom(0.78)} title="Zoom in" aria-label="Zoom in">+</button>
|
|
780
|
+
<button type="button" onClick={() => zoom(1.28)} title="Zoom out" aria-label="Zoom out">−</button>
|
|
781
|
+
</div>
|
|
782
|
+
<div className="graph-3d__help" aria-hidden="true">Drag to rotate <span /> Scroll to zoom <span /> Click a service</div>
|
|
783
|
+
{selectedId && <div className="graph-3d__focus" aria-hidden="true">{Math.max(activeIds.size - 1, 0)} direct connections highlighted</div>}
|
|
784
|
+
</div>
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
|
|
471
788
|
export default function InternalServiceMapPage() {
|
|
472
|
-
const
|
|
789
|
+
const [services, setServices] = useState<RawService[]>(() => graph.services ?? []);
|
|
790
|
+
const nodes = useMemo<ServiceNode[]>(() => services.map((service, index) => ({
|
|
473
791
|
...service,
|
|
474
|
-
...
|
|
792
|
+
...servicePosition(index),
|
|
475
793
|
color: palette[index % palette.length],
|
|
476
|
-
})), []);
|
|
794
|
+
})), [services]);
|
|
477
795
|
const [mode, setMode] = useState<'2d' | '3d'>('2d');
|
|
478
|
-
const [
|
|
796
|
+
const [zoom, setZoom] = useState(1);
|
|
797
|
+
const [pan, setPan] = useState({ x: 0, y: 0 });
|
|
798
|
+
const [panStart, setPanStart] = useState<{ pointerId: number; x: number; y: number; panX: number; panY: number } | null>(null);
|
|
479
799
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
800
|
+
const [isAddingService, setIsAddingService] = useState(false);
|
|
801
|
+
const [newService, setNewService] = useState<NewServiceDraft>({ name: '', category: '', parentId: 'product' });
|
|
480
802
|
const [drafts, setDrafts] = useState<Record<string, DraftDetails>>(() => Object.fromEntries(nodes.map((service) => [
|
|
481
803
|
service.id,
|
|
482
804
|
{
|
|
@@ -487,6 +809,64 @@ export default function InternalServiceMapPage() {
|
|
|
487
809
|
},
|
|
488
810
|
])));
|
|
489
811
|
const selected = nodes.find((service) => service.id === selectedId) ?? null;
|
|
812
|
+
const nodeById = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]);
|
|
813
|
+
const zoomIn = () => setZoom((current) => Math.min(1.45, Number((current + 0.12).toFixed(2))));
|
|
814
|
+
const zoomOut = () => setZoom((current) => Math.max(0.7, Number((current - 0.12).toFixed(2))));
|
|
815
|
+
const resetZoom = () => {
|
|
816
|
+
setZoom(1);
|
|
817
|
+
setPan({ x: 0, y: 0 });
|
|
818
|
+
};
|
|
819
|
+
const startPan = (event: PointerEvent<HTMLElement>) => {
|
|
820
|
+
if (mode !== '2d' || event.button !== 0 || (event.target as HTMLElement).closest('button, a, input, textarea, select')) return;
|
|
821
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
822
|
+
setPanStart({ pointerId: event.pointerId, x: event.clientX, y: event.clientY, panX: pan.x, panY: pan.y });
|
|
823
|
+
};
|
|
824
|
+
const movePan = (event: PointerEvent<HTMLElement>) => {
|
|
825
|
+
if (!panStart || panStart.pointerId !== event.pointerId) return;
|
|
826
|
+
setPan({
|
|
827
|
+
x: panStart.panX + event.clientX - panStart.x,
|
|
828
|
+
y: panStart.panY + event.clientY - panStart.y,
|
|
829
|
+
});
|
|
830
|
+
};
|
|
831
|
+
const stopPan = (event: PointerEvent<HTMLElement>) => {
|
|
832
|
+
if (panStart?.pointerId !== event.pointerId) return;
|
|
833
|
+
setPanStart(null);
|
|
834
|
+
};
|
|
835
|
+
const addService = () => {
|
|
836
|
+
const name = newService.name.trim();
|
|
837
|
+
if (!name) return;
|
|
838
|
+
const baseId = slugify(name);
|
|
839
|
+
const existingIds = new Set(services.map((service) => service.id));
|
|
840
|
+
let id = baseId;
|
|
841
|
+
let suffix = 2;
|
|
842
|
+
while (existingIds.has(id)) {
|
|
843
|
+
id = \`\${baseId}-\${suffix}\`;
|
|
844
|
+
suffix += 1;
|
|
845
|
+
}
|
|
846
|
+
const service: RawService = {
|
|
847
|
+
id,
|
|
848
|
+
key: id,
|
|
849
|
+
name,
|
|
850
|
+
category: newService.category.trim() || 'service',
|
|
851
|
+
confidence: 'manual',
|
|
852
|
+
evidence: [{ type: 'manual', value: 'Added in servicemap UI.', confidence: 'manual' }],
|
|
853
|
+
iconUrl: null,
|
|
854
|
+
parentId: newService.parentId === 'product' ? null : newService.parentId,
|
|
855
|
+
};
|
|
856
|
+
setServices((current) => [...current, service]);
|
|
857
|
+
setDrafts((current) => ({
|
|
858
|
+
...current,
|
|
859
|
+
[id]: {
|
|
860
|
+
subtitle: categoryLabel(service.category),
|
|
861
|
+
use: primaryEvidence(service),
|
|
862
|
+
account: '',
|
|
863
|
+
passwordLocation: '',
|
|
864
|
+
},
|
|
865
|
+
}));
|
|
866
|
+
setSelectedId(id);
|
|
867
|
+
setNewService({ name: '', category: '', parentId: 'product' });
|
|
868
|
+
setIsAddingService(false);
|
|
869
|
+
};
|
|
490
870
|
|
|
491
871
|
return (
|
|
492
872
|
<main className="servicemap-shell">
|
|
@@ -501,6 +881,7 @@ export default function InternalServiceMapPage() {
|
|
|
501
881
|
</div>
|
|
502
882
|
<div className="top-actions">
|
|
503
883
|
<span>{nodes.length} services</span>
|
|
884
|
+
<button type="button" className="add-service-button" onClick={() => setIsAddingService(true)}>+ Add service</button>
|
|
504
885
|
<div className="mode-toggle" aria-label="View mode">
|
|
505
886
|
<button type="button" className={\`mode-button \${mode === '2d' ? 'is-active' : ''}\`} onClick={() => setMode('2d')}>2D</button>
|
|
506
887
|
<button type="button" className={\`mode-button \${mode === '3d' ? 'is-active' : ''}\`} onClick={() => setMode('3d')}>3D</button>
|
|
@@ -508,45 +889,60 @@ export default function InternalServiceMapPage() {
|
|
|
508
889
|
</div>
|
|
509
890
|
</header>
|
|
510
891
|
|
|
511
|
-
<section
|
|
892
|
+
<section
|
|
893
|
+
className={\`map \${panStart ? 'is-panning' : ''} \${selectedId ? 'has-selection' : ''}\`}
|
|
894
|
+
onPointerDown={startPan}
|
|
895
|
+
onPointerMove={movePan}
|
|
896
|
+
onPointerUp={stopPan}
|
|
897
|
+
onPointerCancel={stopPan}
|
|
898
|
+
>
|
|
512
899
|
<div className="intro">
|
|
513
900
|
<strong>{graph.project?.name ?? 'Project'} infrastructure</strong>
|
|
514
901
|
<span>{selected ? \`Tracing \${selected.name}\` : mode === '3d' ? 'Rotate and explore your service relationships' : 'Select a service to trace its dependencies'}</span>
|
|
515
902
|
</div>
|
|
516
903
|
{mode === '2d' ? (
|
|
517
904
|
<>
|
|
518
|
-
<
|
|
519
|
-
|
|
520
|
-
|
|
905
|
+
<div className="map-plane" style={{ '--zoom': zoom, '--pan-x': \`\${pan.x}px\`, '--pan-y': \`\${pan.y}px\` } as CSSProperties}>
|
|
906
|
+
<svg className="edges" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
|
907
|
+
{nodes.map((service) => {
|
|
908
|
+
const parent = service.parentId ? nodeById.get(service.parentId) : null;
|
|
909
|
+
const isActive = selectedId === service.id || selectedId === service.parentId;
|
|
910
|
+
return (
|
|
911
|
+
<path
|
|
912
|
+
key={service.id}
|
|
913
|
+
className={\`edge-line \${isActive ? 'is-active' : selectedId ? 'is-muted' : ''}\`}
|
|
914
|
+
d={parent ? edgePathBetween(parent, service) : edgePath(service)}
|
|
915
|
+
fill="none"
|
|
916
|
+
stroke={isActive ? '#65f0ad' : 'rgba(103, 130, 118, 0.48)'}
|
|
917
|
+
strokeWidth={isActive ? '3' : '0.16'}
|
|
918
|
+
strokeDasharray={isActive ? '10 8' : undefined}
|
|
919
|
+
strokeLinecap={isActive ? 'round' : undefined}
|
|
920
|
+
vectorEffect="non-scaling-stroke"
|
|
921
|
+
/>
|
|
922
|
+
);
|
|
923
|
+
})}
|
|
924
|
+
</svg>
|
|
925
|
+
<div className="hub"><div className="hub-inner"><ProjectMark /></div></div>
|
|
926
|
+
{nodes.map((service) => {
|
|
927
|
+
const isRelated = selectedId === service.parentId || selected?.parentId === service.id;
|
|
928
|
+
return (
|
|
929
|
+
<button
|
|
930
|
+
type="button"
|
|
521
931
|
key={service.id}
|
|
522
|
-
className={\`
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
-
))}
|
|
932
|
+
className={\`service-node \${selectedId === service.id ? 'is-selected' : ''} \${isRelated ? 'is-related' : ''}\`}
|
|
933
|
+
style={{ left: \`\${service.x}%\`, top: \`\${service.y}%\` }}
|
|
934
|
+
onClick={() => setSelectedId(service.id)}
|
|
935
|
+
>
|
|
936
|
+
<ServiceLogo service={service} />
|
|
937
|
+
<div className="service-copy">
|
|
938
|
+
<strong>{service.name}</strong>
|
|
939
|
+
<span>{categoryLabel(service.category)}</span>
|
|
940
|
+
</div>
|
|
941
|
+
<i className={\`status-dot status-\${service.confidence}\`} />
|
|
942
|
+
</button>
|
|
943
|
+
);
|
|
944
|
+
})}
|
|
945
|
+
</div>
|
|
550
946
|
<div className="legend">
|
|
551
947
|
<span><i style={{ background: '#4a7cff' }} />Cloud</span>
|
|
552
948
|
<span><i style={{ background: '#8be4b2' }} />Product</span>
|
|
@@ -554,39 +950,13 @@ export default function InternalServiceMapPage() {
|
|
|
554
950
|
</div>
|
|
555
951
|
</>
|
|
556
952
|
) : (
|
|
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>
|
|
953
|
+
<ServiceGraph3D nodes={nodes} selectedId={selectedId} onSelect={setSelectedId} />
|
|
589
954
|
)}
|
|
955
|
+
<div className="zoom-controls" aria-label="Map zoom controls">
|
|
956
|
+
<button type="button" className="zoom-button" onClick={zoomIn} aria-label="Zoom in">+</button>
|
|
957
|
+
<button type="button" className="zoom-button" onClick={zoomOut} aria-label="Zoom out">−</button>
|
|
958
|
+
<button type="button" className="zoom-button" onClick={resetZoom} aria-label="Reset zoom"><span className="fit-icon" aria-hidden="true" /></button>
|
|
959
|
+
</div>
|
|
590
960
|
<footer className="scan-foot">
|
|
591
961
|
<div>Generated {new Date(graph.generatedAt).toLocaleString()}</div>
|
|
592
962
|
<div>{nodes.length} services detected</div>
|
|
@@ -599,6 +969,15 @@ export default function InternalServiceMapPage() {
|
|
|
599
969
|
onChange={(nextDetails) => setDrafts((current) => ({ ...current, [selected.id]: nextDetails }))}
|
|
600
970
|
/>
|
|
601
971
|
) : null}
|
|
972
|
+
{isAddingService ? (
|
|
973
|
+
<AddServiceModal
|
|
974
|
+
draft={newService}
|
|
975
|
+
nodes={nodes}
|
|
976
|
+
onChange={setNewService}
|
|
977
|
+
onClose={() => setIsAddingService(false)}
|
|
978
|
+
onSubmit={addService}
|
|
979
|
+
/>
|
|
980
|
+
) : null}
|
|
602
981
|
</section>
|
|
603
982
|
</main>
|
|
604
983
|
);
|
|
@@ -675,6 +1054,54 @@ async function prepareProjectLogo(graph, outputRoot) {
|
|
|
675
1054
|
graph.project.logoUrl = `/internalservicemap-assets/project-logo${extension}`;
|
|
676
1055
|
}
|
|
677
1056
|
|
|
1057
|
+
function run(command, args, options = {}) {
|
|
1058
|
+
return new Promise((resolvePromise, reject) => {
|
|
1059
|
+
const child = spawn(command, args, { stdio: 'inherit', ...options });
|
|
1060
|
+
child.on('error', reject);
|
|
1061
|
+
child.on('exit', (code) => {
|
|
1062
|
+
if (code === 0) resolvePromise();
|
|
1063
|
+
else reject(new Error(`${command} ${args.join(' ')} exited with code ${code}`));
|
|
1064
|
+
});
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
async function ensureInteractivePageDependencies(root, routeKind) {
|
|
1069
|
+
if (routeKind === 'static' || routeKind === 'astro') return;
|
|
1070
|
+
const packageJsonPath = join(root, 'package.json');
|
|
1071
|
+
const packageJson = await readJsonOrNull(packageJsonPath);
|
|
1072
|
+
if (!packageJson) return;
|
|
1073
|
+
|
|
1074
|
+
const dependencies = {
|
|
1075
|
+
'react-force-graph-3d': '^1.29.1',
|
|
1076
|
+
three: '^0.185.1',
|
|
1077
|
+
'three-spritetext': '^1.10.0',
|
|
1078
|
+
};
|
|
1079
|
+
const devDependencies = {
|
|
1080
|
+
'@types/three': '^0.185.4',
|
|
1081
|
+
};
|
|
1082
|
+
let changed = false;
|
|
1083
|
+
|
|
1084
|
+
packageJson.dependencies ??= {};
|
|
1085
|
+
for (const [name, version] of Object.entries(dependencies)) {
|
|
1086
|
+
if (packageJson.dependencies[name] || packageJson.devDependencies?.[name]) continue;
|
|
1087
|
+
packageJson.dependencies[name] = version;
|
|
1088
|
+
changed = true;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
packageJson.devDependencies ??= {};
|
|
1092
|
+
for (const [name, version] of Object.entries(devDependencies)) {
|
|
1093
|
+
if (packageJson.dependencies?.[name] || packageJson.devDependencies[name]) continue;
|
|
1094
|
+
packageJson.devDependencies[name] = version;
|
|
1095
|
+
changed = true;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
if (!changed) return;
|
|
1099
|
+
await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
|
1100
|
+
if (existsSync(join(root, 'package-lock.json'))) {
|
|
1101
|
+
await run('npm', ['install'], { cwd: root });
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
|
|
678
1105
|
async function main() {
|
|
679
1106
|
const options = parseArgs();
|
|
680
1107
|
const graph = await readJson(options.input);
|
|
@@ -684,6 +1111,7 @@ async function main() {
|
|
|
684
1111
|
|
|
685
1112
|
await mkdir(dirname(route.path), { recursive: true });
|
|
686
1113
|
await writeFile(route.path, contents);
|
|
1114
|
+
await ensureInteractivePageDependencies(options.root, route.kind);
|
|
687
1115
|
console.log(`Wrote /internalservicemap page to ${route.path}`);
|
|
688
1116
|
}
|
|
689
1117
|
|
|
@@ -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));
|