servicemap 0.1.28 → 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,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
|
-
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
-
import {
|
|
3
|
+
import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { dirname, extname, isAbsolute, join, resolve } from 'node:path';
|
|
5
6
|
|
|
6
7
|
const defaultInput = '.servicemap/internal-servicemap.json';
|
|
7
8
|
|
|
@@ -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';
|
|
@@ -56,6 +65,7 @@ function pageCss() {
|
|
|
56
65
|
.servicemap-topbar { height: 88px; display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 0 28px; border-bottom: 1px solid rgba(255,255,255,0.08); background: #222823; }
|
|
57
66
|
.brand { display: flex; align-items: center; gap: 16px; min-width: 0; }
|
|
58
67
|
.brand-mark { width: 48px; height: 48px; border-radius: 10px; border: 1px solid #2a7958; background: #164d39; color: #7cf1b5; display: grid; place-items: center; box-shadow: 0 0 26px rgba(82,255,166,0.16); font-size: 22px; }
|
|
68
|
+
.brand-mark img, .hub-inner img, .three-core img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }
|
|
59
69
|
.brand h1 { margin: 0; font-size: 20px; line-height: 1; letter-spacing: 0; }
|
|
60
70
|
.brand p, .top-meta { margin: 6px 0 0; color: #8aa097; font-size: 14px; }
|
|
61
71
|
.top-actions { display: flex; align-items: center; gap: 12px; color: #8da59a; font-size: 14px; white-space: nowrap; }
|
|
@@ -63,15 +73,20 @@ function pageCss() {
|
|
|
63
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; }
|
|
64
74
|
.mode-button.is-active { background: #276f53; color: #c9f9df; box-shadow: 0 0 18px rgba(101,240,173,0.16); }
|
|
65
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; }
|
|
66
77
|
.intro { position: absolute; left: 32px; top: 32px; z-index: 20; }
|
|
67
78
|
.intro strong { display: block; color: #779286; font-size: 12px; letter-spacing: 0.22em; text-transform: uppercase; }
|
|
68
79
|
.intro span { display: block; margin-top: 8px; color: #53665d; font-size: 14px; }
|
|
69
|
-
.edges { position: absolute; inset: 0; width: 100%; height: 100%; }
|
|
80
|
+
.edges { position: absolute; inset: 0; z-index: 26; width: 100%; height: 100%; pointer-events: none; }
|
|
81
|
+
.edge-line { transition: opacity 180ms ease, stroke 180ms ease; }
|
|
82
|
+
.edge-line.is-active { stroke: #65f0ad !important; stroke-width: 0.28; stroke-linecap: round; stroke-dasharray: 0.72 0.58; animation: servicemapDash 1.1s linear infinite; filter: drop-shadow(0 0 6px rgba(101,240,173,0.72)); opacity: 1; }
|
|
83
|
+
.edge-line.is-muted { opacity: 0.24; }
|
|
70
84
|
.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); }
|
|
71
85
|
.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); }
|
|
72
86
|
.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); }
|
|
73
87
|
button.service-node { cursor: pointer; color: inherit; font: inherit; text-align: left; }
|
|
74
88
|
.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); }
|
|
89
|
+
.service-node.is-selected { z-index: 27; }
|
|
75
90
|
.logo { width: 48px; height: 48px; flex: 0 0 auto; border-radius: 8px; display: grid; place-items: center; overflow: hidden; color: #fff; font-weight: 700; }
|
|
76
91
|
.logo img { width: 28px; height: 28px; object-fit: contain; filter: invert(1); }
|
|
77
92
|
.service-copy { min-width: 0; }
|
|
@@ -82,6 +97,11 @@ function pageCss() {
|
|
|
82
97
|
.status-medium, .status-low { color: #f1c85b; background: #f1c85b; }
|
|
83
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; }
|
|
84
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; }
|
|
85
105
|
.scan-foot { position: absolute; right: 32px; bottom: 32px; z-index: 20; color: #53665d; text-align: right; font-size: 12px; line-height: 1.6; }
|
|
86
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; }
|
|
87
107
|
.drawer-scrim { position: absolute; inset: 0; z-index: 24; background: rgba(0,0,0,0.58); pointer-events: none; }
|
|
@@ -109,16 +129,21 @@ function pageCss() {
|
|
|
109
129
|
.delete-button { color: #ff6b57; background: transparent; font-weight: 800; }
|
|
110
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); }
|
|
111
131
|
.helper-text { margin: 10px 0 0; color: #687c72; font-size: 12px; line-height: 1.45; }
|
|
112
|
-
.
|
|
113
|
-
.
|
|
114
|
-
.
|
|
115
|
-
.
|
|
116
|
-
.
|
|
117
|
-
.
|
|
118
|
-
.
|
|
119
|
-
.
|
|
120
|
-
.
|
|
121
|
-
.
|
|
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; }
|
|
145
|
+
@keyframes servicemapDash { to { stroke-dashoffset: -1.3; } }
|
|
146
|
+
@keyframes servicemapLinkFlow { to { background-position: 22px 0; } }
|
|
122
147
|
@media (max-width: 920px) {
|
|
123
148
|
.map { min-height: 1120px; }
|
|
124
149
|
.service-node { width: 220px; }
|
|
@@ -147,6 +172,11 @@ function initials(name) {
|
|
|
147
172
|
return name.split(/\s+/).filter(Boolean).slice(0, 2).map((part) => part[0]).join('').toUpperCase();
|
|
148
173
|
}
|
|
149
174
|
|
|
175
|
+
function projectMarkHtml(graph) {
|
|
176
|
+
const logoUrl = graph.project?.logoUrl;
|
|
177
|
+
return logoUrl ? `<img src="${escapeHtml(logoUrl)}" alt="${escapeHtml(graph.project?.name ?? 'Project')} logo">` : '⌘';
|
|
178
|
+
}
|
|
179
|
+
|
|
150
180
|
function htmlBody(graph) {
|
|
151
181
|
const services = graph.services ?? [];
|
|
152
182
|
const access = graph.access ?? {};
|
|
@@ -160,6 +190,7 @@ function htmlBody(graph) {
|
|
|
160
190
|
color: palette[index % palette.length],
|
|
161
191
|
}));
|
|
162
192
|
const edges = nodes.map((node) => `<path d="${edgePath(node)}" fill="none" stroke="rgba(103, 130, 118, 0.48)" stroke-width="0.16" vector-effect="non-scaling-stroke"></path>`).join('');
|
|
193
|
+
const projectMark = projectMarkHtml(graph);
|
|
163
194
|
const serviceNodes = nodes.map((service) => `
|
|
164
195
|
<article class="service-node" style="left:${service.x}%;top:${service.y}%">
|
|
165
196
|
<div class="logo" style="background:${service.color}">${service.iconUrl ? `<img src="${escapeHtml(service.iconUrl)}" alt="">` : escapeHtml(initials(service.name))}</div>
|
|
@@ -175,7 +206,7 @@ function htmlBody(graph) {
|
|
|
175
206
|
<main class="servicemap-shell">
|
|
176
207
|
<header class="servicemap-topbar">
|
|
177
208
|
<div class="brand">
|
|
178
|
-
<div class="brand-mark"
|
|
209
|
+
<div class="brand-mark">${projectMark}</div>
|
|
179
210
|
<div>
|
|
180
211
|
<h1>servicemap</h1>
|
|
181
212
|
<p>${escapeHtml(graph.project?.name ?? 'Project')} command center</p>
|
|
@@ -195,7 +226,7 @@ function htmlBody(graph) {
|
|
|
195
226
|
<span>Select a service to trace its dependencies</span>
|
|
196
227
|
</div>
|
|
197
228
|
<svg class="edges" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">${edges}</svg>
|
|
198
|
-
<div class="hub"><div class="hub-inner"
|
|
229
|
+
<div class="hub"><div class="hub-inner">${projectMark}</div></div>
|
|
199
230
|
${serviceNodes || '<div class="intro"><span>No external services were detected yet.</span></div>'}
|
|
200
231
|
${accessNotice}
|
|
201
232
|
<div class="legend">
|
|
@@ -231,7 +262,11 @@ ${htmlBody(graph)}
|
|
|
231
262
|
function interactiveTsxPage(graph) {
|
|
232
263
|
return `'use client';
|
|
233
264
|
|
|
234
|
-
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';
|
|
235
270
|
|
|
236
271
|
const css = ${JSON.stringify(pageCss())};
|
|
237
272
|
type Evidence = { type?: string; value?: string; confidence?: string };
|
|
@@ -239,10 +274,15 @@ type RawService = { id: string; name: string; key?: string; category?: string; c
|
|
|
239
274
|
type ServiceNode = RawService & { color: string; x: number; y: number };
|
|
240
275
|
type DraftDetails = { subtitle: string; use: string; account: string; passwordLocation: string };
|
|
241
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 };
|
|
242
281
|
|
|
243
|
-
const graph = ${JSON.stringify(graph)} as { generatedAt: string; project?: { name?: string }; services?: RawService[] };
|
|
282
|
+
const graph = ${JSON.stringify(graph)} as { generatedAt: string; project?: { name?: string; logoUrl?: string }; services?: RawService[] };
|
|
244
283
|
const palette = ${JSON.stringify(palette)} as string[];
|
|
245
284
|
const layout = ${JSON.stringify(layout)} as Array<{ x: number; y: number }>;
|
|
285
|
+
const ForceGraph3D = dynamic(() => import('react-force-graph-3d'), { ssr: false });
|
|
246
286
|
|
|
247
287
|
function categoryLabel(value?: string | null) {
|
|
248
288
|
return String(value ?? 'service').replace(/-/g, ' ');
|
|
@@ -260,41 +300,85 @@ function edgePath(node: ServiceNode) {
|
|
|
260
300
|
return \`M \${fromX} \${node.y} H \${midX} V 50 H \${endX}\`;
|
|
261
301
|
}
|
|
262
302
|
|
|
263
|
-
function
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
const z = ((index % 5) - 2) * 32;
|
|
267
|
-
return {
|
|
268
|
-
x: Math.cos(angle) * radius,
|
|
269
|
-
y: Math.sin(angle) * radius,
|
|
270
|
-
z,
|
|
271
|
-
angle: (angle * 180) / Math.PI,
|
|
272
|
-
length: radius,
|
|
273
|
-
};
|
|
303
|
+
function primaryEvidence(service: RawService) {
|
|
304
|
+
const evidence = service.evidence?.[0];
|
|
305
|
+
return evidence ? \`\${evidence.type}: \${evidence.value}\` : 'No evidence captured yet.';
|
|
274
306
|
}
|
|
275
307
|
|
|
276
|
-
function
|
|
277
|
-
|
|
278
|
-
return {
|
|
279
|
-
'--x': \`\${position.x}px\`,
|
|
280
|
-
'--y': \`\${position.y}px\`,
|
|
281
|
-
'--z': \`\${position.z}px\`,
|
|
282
|
-
'--color': color,
|
|
283
|
-
} as CSSProperties;
|
|
308
|
+
function endpointId(endpoint: string | number | GraphNode3D) {
|
|
309
|
+
return typeof endpoint === 'object' ? endpoint.id : String(endpoint);
|
|
284
310
|
}
|
|
285
311
|
|
|
286
|
-
function
|
|
287
|
-
|
|
288
|
-
return {
|
|
289
|
-
'--angle': \`\${position.angle}deg\`,
|
|
290
|
-
'--length': \`\${position.length}px\`,
|
|
291
|
-
'--z': \`\${position.z}px\`,
|
|
292
|
-
} as CSSProperties;
|
|
312
|
+
function graphNode(node: NodeObject) {
|
|
313
|
+
return node as GraphNode3D;
|
|
293
314
|
}
|
|
294
315
|
|
|
295
|
-
function
|
|
296
|
-
|
|
297
|
-
|
|
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;
|
|
298
382
|
}
|
|
299
383
|
|
|
300
384
|
function serviceLinks(service: RawService): ServiceLink[] {
|
|
@@ -325,6 +409,15 @@ function ServiceLogo({ service }: { service: RawService & { color: string } }) {
|
|
|
325
409
|
);
|
|
326
410
|
}
|
|
327
411
|
|
|
412
|
+
function ProjectMark() {
|
|
413
|
+
return graph.project?.logoUrl ? (
|
|
414
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
415
|
+
<img src={graph.project.logoUrl} alt={\`\${graph.project?.name ?? 'Project'} logo\`} />
|
|
416
|
+
) : (
|
|
417
|
+
<>⌘</>
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
328
421
|
function DetailsPanel({
|
|
329
422
|
service,
|
|
330
423
|
details,
|
|
@@ -418,6 +511,170 @@ function DetailsPanel({
|
|
|
418
511
|
);
|
|
419
512
|
}
|
|
420
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
|
+
|
|
421
678
|
export default function InternalServiceMapPage() {
|
|
422
679
|
const nodes = useMemo<ServiceNode[]>(() => (graph.services ?? []).slice(0, layout.length).map((service, index) => ({
|
|
423
680
|
...service,
|
|
@@ -425,7 +682,7 @@ export default function InternalServiceMapPage() {
|
|
|
425
682
|
color: palette[index % palette.length],
|
|
426
683
|
})), []);
|
|
427
684
|
const [mode, setMode] = useState<'2d' | '3d'>('2d');
|
|
428
|
-
const [
|
|
685
|
+
const [zoom, setZoom] = useState(1);
|
|
429
686
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
430
687
|
const [drafts, setDrafts] = useState<Record<string, DraftDetails>>(() => Object.fromEntries(nodes.map((service) => [
|
|
431
688
|
service.id,
|
|
@@ -437,13 +694,16 @@ export default function InternalServiceMapPage() {
|
|
|
437
694
|
},
|
|
438
695
|
])));
|
|
439
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);
|
|
440
700
|
|
|
441
701
|
return (
|
|
442
702
|
<main className="servicemap-shell">
|
|
443
703
|
<style dangerouslySetInnerHTML={{ __html: css }} />
|
|
444
704
|
<header className="servicemap-topbar">
|
|
445
705
|
<div className="brand">
|
|
446
|
-
<div className="brand-mark"
|
|
706
|
+
<div className="brand-mark"><ProjectMark /></div>
|
|
447
707
|
<div>
|
|
448
708
|
<h1>servicemap</h1>
|
|
449
709
|
<p>{graph.project?.name ?? 'Project'} command center</p>
|
|
@@ -465,64 +725,54 @@ export default function InternalServiceMapPage() {
|
|
|
465
725
|
</div>
|
|
466
726
|
{mode === '2d' ? (
|
|
467
727
|
<>
|
|
468
|
-
<
|
|
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>
|
|
469
745
|
{nodes.map((service) => (
|
|
470
|
-
<path key={service.id} d={edgePath(service)} fill="none" stroke="rgba(103, 130, 118, 0.48)" strokeWidth="0.16" vectorEffect="non-scaling-stroke" />
|
|
471
|
-
))}
|
|
472
|
-
</svg>
|
|
473
|
-
<div className="hub"><div className="hub-inner">⌘</div></div>
|
|
474
|
-
{nodes.map((service) => (
|
|
475
|
-
<button
|
|
476
|
-
type="button"
|
|
477
|
-
key={service.id}
|
|
478
|
-
className={\`service-node \${selectedId === service.id ? 'is-selected' : ''}\`}
|
|
479
|
-
style={{ left: \`\${service.x}%\`, top: \`\${service.y}%\` }}
|
|
480
|
-
onClick={() => setSelectedId(service.id)}
|
|
481
|
-
>
|
|
482
|
-
<ServiceLogo service={service} />
|
|
483
|
-
<div className="service-copy">
|
|
484
|
-
<strong>{service.name}</strong>
|
|
485
|
-
<span>{categoryLabel(service.category)}</span>
|
|
486
|
-
</div>
|
|
487
|
-
<i className={\`status-dot status-\${service.confidence}\`} />
|
|
488
|
-
</button>
|
|
489
|
-
))}
|
|
490
|
-
<div className="legend">
|
|
491
|
-
<span><i style={{ background: '#4a7cff' }} />Cloud</span>
|
|
492
|
-
<span><i style={{ background: '#8be4b2' }} />Product</span>
|
|
493
|
-
<span><i style={{ background: '#65f0ad' }} />Active path</span>
|
|
494
|
-
</div>
|
|
495
|
-
</>
|
|
496
|
-
) : (
|
|
497
|
-
<div className="three-scene" aria-label="3D service graph">
|
|
498
|
-
<div className="three-space" style={{ '--rx': \`\${rotation.x}deg\`, '--rz': \`\${rotation.z}deg\` } as CSSProperties}>
|
|
499
|
-
{nodes.map((service, index) => (
|
|
500
|
-
<span key={\`\${service.id}-link\`} className="three-link" style={threeLinkStyle(index, nodes.length)} />
|
|
501
|
-
))}
|
|
502
|
-
<div className="three-core">⌘</div>
|
|
503
|
-
{nodes.map((service, index) => (
|
|
504
746
|
<button
|
|
505
747
|
type="button"
|
|
506
748
|
key={service.id}
|
|
507
|
-
className={\`
|
|
508
|
-
style={
|
|
509
|
-
onPointerMove={(event) => {
|
|
510
|
-
if (event.buttons !== 1) return;
|
|
511
|
-
setRotation((current) => ({
|
|
512
|
-
x: Math.max(24, Math.min(72, current.x - event.movementY * 0.25)),
|
|
513
|
-
z: current.z + event.movementX * 0.25,
|
|
514
|
-
}));
|
|
515
|
-
}}
|
|
749
|
+
className={\`service-node \${selectedId === service.id ? 'is-selected' : ''}\`}
|
|
750
|
+
style={{ left: \`\${service.x}%\`, top: \`\${service.y}%\` }}
|
|
516
751
|
onClick={() => setSelectedId(service.id)}
|
|
517
752
|
>
|
|
518
|
-
<
|
|
519
|
-
<
|
|
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}\`} />
|
|
520
759
|
</button>
|
|
521
760
|
))}
|
|
522
761
|
</div>
|
|
523
|
-
<div className="
|
|
524
|
-
|
|
762
|
+
<div className="legend">
|
|
763
|
+
<span><i style={{ background: '#4a7cff' }} />Cloud</span>
|
|
764
|
+
<span><i style={{ background: '#8be4b2' }} />Product</span>
|
|
765
|
+
<span><i style={{ background: '#65f0ad' }} />Active path</span>
|
|
766
|
+
</div>
|
|
767
|
+
</>
|
|
768
|
+
) : (
|
|
769
|
+
<ServiceGraph3D nodes={nodes} selectedId={selectedId} onSelect={setSelectedId} />
|
|
525
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>
|
|
526
776
|
<footer className="scan-foot">
|
|
527
777
|
<div>Generated {new Date(graph.generatedAt).toLocaleString()}</div>
|
|
528
778
|
<div>{nodes.length} services detected</div>
|
|
@@ -594,14 +844,81 @@ const html = ${JSON.stringify(htmlBody(graph))};
|
|
|
594
844
|
`;
|
|
595
845
|
}
|
|
596
846
|
|
|
847
|
+
async function prepareProjectLogo(graph, outputRoot) {
|
|
848
|
+
const logoPath = graph.project?.logo?.path;
|
|
849
|
+
if (!logoPath) return;
|
|
850
|
+
|
|
851
|
+
const sourceRoot = graph.project?.root ? resolve(graph.project.root) : outputRoot;
|
|
852
|
+
const sourcePath = isAbsolute(logoPath) ? logoPath : resolve(sourceRoot, logoPath);
|
|
853
|
+
if (!existsSync(sourcePath)) return;
|
|
854
|
+
|
|
855
|
+
const extension = extname(sourcePath) || '.png';
|
|
856
|
+
const assetDir = join(outputRoot, 'public', 'internalservicemap-assets');
|
|
857
|
+
const assetPath = join(assetDir, `project-logo${extension}`);
|
|
858
|
+
await mkdir(assetDir, { recursive: true });
|
|
859
|
+
await copyFile(sourcePath, assetPath);
|
|
860
|
+
|
|
861
|
+
graph.project.logoUrl = `/internalservicemap-assets/project-logo${extension}`;
|
|
862
|
+
}
|
|
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
|
+
|
|
597
912
|
async function main() {
|
|
598
913
|
const options = parseArgs();
|
|
599
914
|
const graph = await readJson(options.input);
|
|
915
|
+
await prepareProjectLogo(graph, options.root);
|
|
600
916
|
const route = routeFor(options.root, graph.project?.framework);
|
|
601
917
|
const contents = route.kind === 'astro' ? astroPage(graph) : route.kind === 'static' ? staticHtml(graph) : tsxPage(graph);
|
|
602
918
|
|
|
603
919
|
await mkdir(dirname(route.path), { recursive: true });
|
|
604
920
|
await writeFile(route.path, contents);
|
|
921
|
+
await ensureInteractivePageDependencies(options.root, route.kind);
|
|
605
922
|
console.log(`Wrote /internalservicemap page to ${route.path}`);
|
|
606
923
|
}
|
|
607
924
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
|
-
import { readFile, readdir,
|
|
3
|
+
import { readFile, readdir, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { basename, dirname, extname, join, relative, resolve } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
|
|
@@ -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));
|
|
@@ -240,6 +270,59 @@ function buildEdges(projectName, services) {
|
|
|
240
270
|
}));
|
|
241
271
|
}
|
|
242
272
|
|
|
273
|
+
async function findProjectLogo(root, files) {
|
|
274
|
+
const appIconManifests = files
|
|
275
|
+
.filter((file) => file.endsWith('AppIcon.appiconset/Contents.json'))
|
|
276
|
+
.sort((a, b) => scoreLogoManifest(b) - scoreLogoManifest(a));
|
|
277
|
+
|
|
278
|
+
for (const manifestPath of appIconManifests) {
|
|
279
|
+
const manifest = await readJson(manifestPath);
|
|
280
|
+
const images = Array.isArray(manifest?.images) ? manifest.images : [];
|
|
281
|
+
const candidates = images
|
|
282
|
+
.filter((image) => image?.filename)
|
|
283
|
+
.map((image) => ({
|
|
284
|
+
file: join(dirname(manifestPath), image.filename),
|
|
285
|
+
score: scoreAppIconImage(image),
|
|
286
|
+
}))
|
|
287
|
+
.filter((candidate) => existsSync(candidate.file))
|
|
288
|
+
.sort((a, b) => b.score - a.score);
|
|
289
|
+
|
|
290
|
+
if (candidates[0]) {
|
|
291
|
+
return {
|
|
292
|
+
path: relative(root, candidates[0].file),
|
|
293
|
+
source: 'asset-catalog-app-icon',
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const namedLogo = files.find((file) => /(^|[/\\])(app-?icon|logo|favicon)\.(png|jpg|jpeg|webp|svg|ico)$/i.test(file));
|
|
299
|
+
if (namedLogo) {
|
|
300
|
+
return {
|
|
301
|
+
path: relative(root, namedLogo),
|
|
302
|
+
source: 'named-image-file',
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function scoreLogoManifest(path) {
|
|
310
|
+
let score = 0;
|
|
311
|
+
if (/Assets\.xcassets[/\\]AppIcon\.appiconset/i.test(path)) score += 10;
|
|
312
|
+
if (/Assets 2\.xcassets/i.test(path)) score += 20;
|
|
313
|
+
return score;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function scoreAppIconImage(image) {
|
|
317
|
+
const size = Number.parseInt(String(image.size ?? '0').split('x')[0], 10) || 0;
|
|
318
|
+
let score = size;
|
|
319
|
+
const filename = String(image.filename ?? '').toLowerCase();
|
|
320
|
+
if (filename.includes('dark') || filename.includes('darker')) score += 100;
|
|
321
|
+
if (filename.includes('tinted')) score -= 50;
|
|
322
|
+
if (image.platform === 'ios') score += 25;
|
|
323
|
+
return score;
|
|
324
|
+
}
|
|
325
|
+
|
|
243
326
|
async function main() {
|
|
244
327
|
const options = parseArgs();
|
|
245
328
|
const packageJsonPath = join(options.root, 'package.json');
|
|
@@ -273,6 +356,7 @@ async function main() {
|
|
|
273
356
|
configFiles: configFiles.map((file) => basename(file)),
|
|
274
357
|
database,
|
|
275
358
|
});
|
|
359
|
+
const logo = await findProjectLogo(options.root, files);
|
|
276
360
|
|
|
277
361
|
const graph = {
|
|
278
362
|
generatedAt: new Date().toISOString(),
|
|
@@ -280,6 +364,7 @@ async function main() {
|
|
|
280
364
|
name: projectName,
|
|
281
365
|
root: options.root,
|
|
282
366
|
framework: detectFramework(options.root, packageJson, files),
|
|
367
|
+
logo,
|
|
283
368
|
},
|
|
284
369
|
access: {
|
|
285
370
|
allowedEmails: options.allowedEmails,
|