servicemap 0.1.34 → 0.1.40
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
package/servicemap/SKILL.md
CHANGED
|
@@ -24,6 +24,9 @@ If no website can be found, create a desktop app fallback at `servicemap-desktop
|
|
|
24
24
|
7. If the scan reports `framework: static` or no website route can be safely attached, run `scripts/generate-desktop-app.mjs --root <project-root>` to create `servicemap-desktop`.
|
|
25
25
|
8. Adapt the generated surface to the project's existing design system only when the route is clear and the edits are low-risk.
|
|
26
26
|
9. Run the project's relevant build, lint, and focused tests.
|
|
27
|
+
Open the generated map in a browser and verify both 2D and 3D modes.
|
|
28
|
+
Click a service and verify its details panel and animated illuminated dashed connection to its parent.
|
|
29
|
+
Desktop output uses the same interactive component as website output; missing interactions are generator bugs to fix, not controls to remove.
|
|
27
30
|
10. If a website page was created, tell the user it is only local until deployed and ask whether they want to deploy it now.
|
|
28
31
|
Do not deploy unless the user explicitly says yes.
|
|
29
32
|
If they approve, use the project's existing deployment path, preferring `npm run deploy` when present, then provider-specific project configuration such as Vercel or Netlify.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { interactiveTsxPage, prepareProjectLogo } from './generate-page.mjs';
|
|
4
5
|
|
|
5
6
|
const defaultInput = '.servicemap/internal-servicemap.json';
|
|
6
7
|
const defaultOutput = 'servicemap-desktop';
|
|
@@ -48,6 +49,9 @@ function packageJson(graph) {
|
|
|
48
49
|
typescript: '^5.0.0',
|
|
49
50
|
react: '^19.0.0',
|
|
50
51
|
'react-dom': '^19.0.0',
|
|
52
|
+
'react-force-graph-3d': '^1.29.1',
|
|
53
|
+
three: '^0.185.1',
|
|
54
|
+
'three-spritetext': '^1.10.0',
|
|
51
55
|
'@tauri-apps/api': '^2.0.0',
|
|
52
56
|
},
|
|
53
57
|
devDependencies: {
|
|
@@ -86,498 +90,6 @@ function indexHtml() {
|
|
|
86
90
|
`;
|
|
87
91
|
}
|
|
88
92
|
|
|
89
|
-
function mainJsx() {
|
|
90
|
-
return `import React from 'react';
|
|
91
|
-
import { createRoot } from 'react-dom/client';
|
|
92
|
-
import graph from './servicemap-data.json';
|
|
93
|
-
import './styles.css';
|
|
94
|
-
|
|
95
|
-
function strongestEvidence(service) {
|
|
96
|
-
const evidence = service.evidence?.[0];
|
|
97
|
-
return evidence ? \`\${evidence.type}: \${evidence.value}\` : 'No evidence captured';
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function categoryLabel(value) {
|
|
101
|
-
return String(value ?? 'service').replace(/-/g, ' ');
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function categoryClass(value) {
|
|
105
|
-
return String(value ?? 'service').replace(/[^a-z0-9]+/gi, '-').toLowerCase();
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function layoutServices(services) {
|
|
109
|
-
const left = [];
|
|
110
|
-
const right = [];
|
|
111
|
-
services.forEach((service, index) => {
|
|
112
|
-
if (index % 2 === 0) left.push(service);
|
|
113
|
-
else right.push(service);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
const place = (items, side) => items.map((service, index) => {
|
|
117
|
-
const count = Math.max(items.length, 1);
|
|
118
|
-
const y = 15 + ((index + 0.5) * 70) / count;
|
|
119
|
-
const x = side === 'left' ? 10 + (index % 2) * 13 : 71 + (index % 2) * 10;
|
|
120
|
-
return { service, x, y, side };
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
return [...place(left, 'left'), ...place(right, 'right')];
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function App() {
|
|
127
|
-
const services = graph.services ?? [];
|
|
128
|
-
const highConfidence = services.filter((service) => service.confidence === 'high').length;
|
|
129
|
-
const nodes = layoutServices(services);
|
|
130
|
-
|
|
131
|
-
return (
|
|
132
|
-
<main className="appShell">
|
|
133
|
-
<header className="topbar">
|
|
134
|
-
<div className="brand">
|
|
135
|
-
<div className="brandIcon" aria-hidden="true">⌘</div>
|
|
136
|
-
<div>
|
|
137
|
-
<h1>service-map</h1>
|
|
138
|
-
<p>{graph.project?.name ?? 'Internal'} command center</p>
|
|
139
|
-
</div>
|
|
140
|
-
</div>
|
|
141
|
-
<div className="toolbar" aria-label="Service map actions">
|
|
142
|
-
<span className="identity">{graph.project?.framework ?? 'static'}</span>
|
|
143
|
-
<button type="button">2D</button>
|
|
144
|
-
<button type="button" className="ghost">3D</button>
|
|
145
|
-
<button type="button" className="ghost">Import</button>
|
|
146
|
-
<button type="button" className="primary">+ Add service</button>
|
|
147
|
-
</div>
|
|
148
|
-
</header>
|
|
149
|
-
|
|
150
|
-
<section className="mapCanvas" aria-label="Detected service map">
|
|
151
|
-
<div className="sectionLabel">
|
|
152
|
-
<span>{(graph.project?.name ?? 'project').toUpperCase()} INFRASTRUCTURE</span>
|
|
153
|
-
<small>{services.length} services · {highConfidence} high confidence</small>
|
|
154
|
-
</div>
|
|
155
|
-
|
|
156
|
-
<svg className="edges" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
|
157
|
-
{nodes.map((node) => {
|
|
158
|
-
const startX = node.side === 'left' ? node.x + 13 : node.x;
|
|
159
|
-
const endX = 50;
|
|
160
|
-
const midX = node.side === 'left' ? 38 : 62;
|
|
161
|
-
return (
|
|
162
|
-
<path
|
|
163
|
-
key={node.service.id}
|
|
164
|
-
d={\`M \${startX} \${node.y} H \${midX} V 50 H \${endX}\`}
|
|
165
|
-
className={node.service.confidence === 'high' ? 'edge edgeHigh' : 'edge'}
|
|
166
|
-
/>
|
|
167
|
-
);
|
|
168
|
-
})}
|
|
169
|
-
</svg>
|
|
170
|
-
|
|
171
|
-
<div className="centerNode" aria-label="Project">
|
|
172
|
-
<div className="coreIcon" aria-hidden="true">⌬</div>
|
|
173
|
-
</div>
|
|
174
|
-
|
|
175
|
-
{nodes.map(({ service, x, y }) => (
|
|
176
|
-
<article
|
|
177
|
-
className={\`serviceNode \${service.confidence === 'high' ? 'isHigh' : ''}\`}
|
|
178
|
-
key={service.id}
|
|
179
|
-
style={{ left: \`\${x}%\`, top: \`\${y}%\` }}
|
|
180
|
-
title={strongestEvidence(service)}
|
|
181
|
-
>
|
|
182
|
-
<div className={\`logo logo-\${categoryClass(service.category)}\`} aria-hidden="true">
|
|
183
|
-
{service.iconUrl ? <img src={service.iconUrl} alt="" /> : service.name.slice(0, 2).toUpperCase()}
|
|
184
|
-
</div>
|
|
185
|
-
<div className="nameBlock">
|
|
186
|
-
<strong>{service.name}</strong>
|
|
187
|
-
<span>{categoryLabel(service.category)}</span>
|
|
188
|
-
</div>
|
|
189
|
-
<span className={\`statusDot \${service.confidence === 'high' ? 'active' : 'pending'}\`} />
|
|
190
|
-
</article>
|
|
191
|
-
))}
|
|
192
|
-
|
|
193
|
-
{!services.length && (
|
|
194
|
-
<div className="empty">
|
|
195
|
-
<strong>No external services detected</strong>
|
|
196
|
-
<span>Run the scanner again after adding integrations.</span>
|
|
197
|
-
</div>
|
|
198
|
-
)}
|
|
199
|
-
|
|
200
|
-
<div className="legend">
|
|
201
|
-
<span><b className="blue" /> Cloud</span>
|
|
202
|
-
<span><b className="green" /> Active path</span>
|
|
203
|
-
<span><b className="yellow" /> Lower confidence</span>
|
|
204
|
-
</div>
|
|
205
|
-
</section>
|
|
206
|
-
</main>
|
|
207
|
-
);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
createRoot(document.getElementById('root')).render(<App />);
|
|
211
|
-
`;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function stylesCss() {
|
|
215
|
-
return `:root {
|
|
216
|
-
color: #eef7f2;
|
|
217
|
-
background: #020806;
|
|
218
|
-
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
* {
|
|
222
|
-
box-sizing: border-box;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
body {
|
|
226
|
-
margin: 0;
|
|
227
|
-
min-width: 320px;
|
|
228
|
-
min-height: 100vh;
|
|
229
|
-
background: #020806;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
button {
|
|
233
|
-
border: 0;
|
|
234
|
-
border-radius: 8px;
|
|
235
|
-
min-height: 42px;
|
|
236
|
-
padding: 0 16px;
|
|
237
|
-
color: #e8f5ee;
|
|
238
|
-
background: #101a16;
|
|
239
|
-
font: inherit;
|
|
240
|
-
font-weight: 700;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
.appShell {
|
|
244
|
-
min-height: 100vh;
|
|
245
|
-
background: #020806;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
.topbar {
|
|
249
|
-
height: 86px;
|
|
250
|
-
padding: 18px 28px;
|
|
251
|
-
display: flex;
|
|
252
|
-
align-items: center;
|
|
253
|
-
justify-content: space-between;
|
|
254
|
-
gap: 20px;
|
|
255
|
-
background: #202622;
|
|
256
|
-
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
.brand {
|
|
260
|
-
display: flex;
|
|
261
|
-
align-items: center;
|
|
262
|
-
gap: 14px;
|
|
263
|
-
min-width: 0;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
.brandIcon {
|
|
267
|
-
width: 48px;
|
|
268
|
-
height: 48px;
|
|
269
|
-
border-radius: 12px;
|
|
270
|
-
display: grid;
|
|
271
|
-
place-items: center;
|
|
272
|
-
background: #1f704f;
|
|
273
|
-
border: 1px solid #38c987;
|
|
274
|
-
box-shadow: 0 0 24px rgba(56, 201, 135, 0.25);
|
|
275
|
-
color: #c9f8df;
|
|
276
|
-
font-size: 25px;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
h1 {
|
|
280
|
-
margin: 0 0 3px;
|
|
281
|
-
font-size: 22px;
|
|
282
|
-
line-height: 1;
|
|
283
|
-
letter-spacing: 0;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
p {
|
|
287
|
-
margin: 0;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
.brand p,
|
|
291
|
-
.nameBlock span,
|
|
292
|
-
.sectionLabel,
|
|
293
|
-
.legend {
|
|
294
|
-
color: rgba(238, 247, 242, 0.54);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
.toolbar {
|
|
298
|
-
display: flex;
|
|
299
|
-
align-items: center;
|
|
300
|
-
gap: 10px;
|
|
301
|
-
min-width: 0;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
.identity {
|
|
305
|
-
color: #93c8ac;
|
|
306
|
-
font-size: 13px;
|
|
307
|
-
font-weight: 800;
|
|
308
|
-
margin-right: 10px;
|
|
309
|
-
white-space: nowrap;
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
.toolbar .primary {
|
|
313
|
-
background: #247b58;
|
|
314
|
-
box-shadow: inset 0 0 0 1px rgba(104, 255, 179, 0.25);
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
.toolbar .ghost {
|
|
318
|
-
color: rgba(238, 247, 242, 0.72);
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
.mapCanvas {
|
|
322
|
-
position: relative;
|
|
323
|
-
min-height: calc(100vh - 86px);
|
|
324
|
-
overflow: hidden;
|
|
325
|
-
background:
|
|
326
|
-
radial-gradient(circle at center, rgba(37, 128, 88, 0.16), transparent 34%),
|
|
327
|
-
radial-gradient(circle at 1px 1px, rgba(115, 255, 183, 0.16) 1px, transparent 1px),
|
|
328
|
-
#020806;
|
|
329
|
-
background-size: auto, 28px 28px, auto;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
.sectionLabel {
|
|
333
|
-
position: absolute;
|
|
334
|
-
top: 28px;
|
|
335
|
-
left: 32px;
|
|
336
|
-
right: 32px;
|
|
337
|
-
display: flex;
|
|
338
|
-
justify-content: space-between;
|
|
339
|
-
gap: 18px;
|
|
340
|
-
font-size: 13px;
|
|
341
|
-
font-weight: 800;
|
|
342
|
-
letter-spacing: 0.16em;
|
|
343
|
-
text-transform: uppercase;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
.sectionLabel small {
|
|
347
|
-
letter-spacing: 0.08em;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
.edges {
|
|
351
|
-
position: absolute;
|
|
352
|
-
inset: 0;
|
|
353
|
-
width: 100%;
|
|
354
|
-
height: 100%;
|
|
355
|
-
pointer-events: none;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
.edge {
|
|
359
|
-
fill: none;
|
|
360
|
-
stroke: rgba(121, 158, 140, 0.34);
|
|
361
|
-
stroke-width: 0.12;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
.edgeHigh {
|
|
365
|
-
stroke: rgba(115, 255, 183, 0.5);
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
.centerNode {
|
|
369
|
-
position: absolute;
|
|
370
|
-
left: 50%;
|
|
371
|
-
top: 50%;
|
|
372
|
-
width: 112px;
|
|
373
|
-
height: 112px;
|
|
374
|
-
transform: translate(-50%, -50%);
|
|
375
|
-
display: grid;
|
|
376
|
-
place-items: center;
|
|
377
|
-
border-radius: 26px;
|
|
378
|
-
background: rgba(15, 31, 24, 0.88);
|
|
379
|
-
border: 1px solid rgba(114, 255, 183, 0.28);
|
|
380
|
-
box-shadow: 0 0 0 9px rgba(56, 201, 135, 0.08), 0 0 45px rgba(56, 201, 135, 0.18);
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
.coreIcon {
|
|
384
|
-
width: 78px;
|
|
385
|
-
height: 78px;
|
|
386
|
-
display: grid;
|
|
387
|
-
place-items: center;
|
|
388
|
-
border-radius: 20px;
|
|
389
|
-
background: #f7fbf7;
|
|
390
|
-
color: #101513;
|
|
391
|
-
font-size: 38px;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
.serviceNode {
|
|
395
|
-
position: absolute;
|
|
396
|
-
width: clamp(190px, 16vw, 260px);
|
|
397
|
-
min-height: 70px;
|
|
398
|
-
transform: translateY(-50%);
|
|
399
|
-
display: grid;
|
|
400
|
-
grid-template-columns: 48px minmax(0, 1fr) 12px;
|
|
401
|
-
align-items: center;
|
|
402
|
-
gap: 12px;
|
|
403
|
-
padding: 12px;
|
|
404
|
-
border-radius: 14px;
|
|
405
|
-
background: linear-gradient(180deg, rgba(22, 33, 27, 0.98), rgba(12, 20, 16, 0.96));
|
|
406
|
-
border: 1px solid rgba(158, 202, 176, 0.24);
|
|
407
|
-
box-shadow: 0 12px 26px rgba(0, 0, 0, 0.34), inset 0 0 0 1px rgba(255, 255, 255, 0.03);
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
.serviceNode.isHigh {
|
|
411
|
-
box-shadow: 0 12px 26px rgba(0, 0, 0, 0.34), 0 0 24px rgba(56, 201, 135, 0.08);
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
.logo {
|
|
415
|
-
width: 48px;
|
|
416
|
-
height: 48px;
|
|
417
|
-
border-radius: 12px;
|
|
418
|
-
background: #24302b;
|
|
419
|
-
color: #fff;
|
|
420
|
-
display: grid;
|
|
421
|
-
place-items: center;
|
|
422
|
-
overflow: hidden;
|
|
423
|
-
flex: 0 0 auto;
|
|
424
|
-
font-size: 12px;
|
|
425
|
-
font-weight: 800;
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
.logo img {
|
|
429
|
-
width: 100%;
|
|
430
|
-
height: 100%;
|
|
431
|
-
object-fit: contain;
|
|
432
|
-
padding: 7px;
|
|
433
|
-
filter: brightness(0) invert(1);
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
.logo-cloud {
|
|
437
|
-
background: #2d72f6;
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
.logo-developer-tools {
|
|
441
|
-
background: #202833;
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
.logo-billing {
|
|
445
|
-
background: #635bff;
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
.logo-email,
|
|
449
|
-
.logo-collaboration {
|
|
450
|
-
background: #f45d48;
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
.logo-ai {
|
|
454
|
-
background: #1f7a55;
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
.logo-monitoring {
|
|
458
|
-
background: #6b4bd8;
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
.logo-auth {
|
|
462
|
-
background: #28a37a;
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
.logo-database {
|
|
466
|
-
background: #f57c00;
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
.nameBlock {
|
|
470
|
-
min-width: 0;
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
.nameBlock strong {
|
|
474
|
-
display: block;
|
|
475
|
-
overflow: hidden;
|
|
476
|
-
text-overflow: ellipsis;
|
|
477
|
-
white-space: nowrap;
|
|
478
|
-
font-size: 15px;
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
.nameBlock span {
|
|
482
|
-
display: block;
|
|
483
|
-
margin-top: 4px;
|
|
484
|
-
overflow: hidden;
|
|
485
|
-
text-overflow: ellipsis;
|
|
486
|
-
white-space: nowrap;
|
|
487
|
-
font-size: 12px;
|
|
488
|
-
text-transform: capitalize;
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
.statusDot {
|
|
492
|
-
width: 8px;
|
|
493
|
-
height: 8px;
|
|
494
|
-
border-radius: 999px;
|
|
495
|
-
background: #eac94f;
|
|
496
|
-
box-shadow: 0 0 12px rgba(234, 201, 79, 0.9);
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
.statusDot.active {
|
|
500
|
-
background: #69f6b1;
|
|
501
|
-
box-shadow: 0 0 12px rgba(105, 246, 177, 0.9);
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
.empty {
|
|
505
|
-
position: absolute;
|
|
506
|
-
left: 50%;
|
|
507
|
-
top: 50%;
|
|
508
|
-
transform: translate(-50%, 90px);
|
|
509
|
-
display: grid;
|
|
510
|
-
gap: 6px;
|
|
511
|
-
text-align: center;
|
|
512
|
-
color: rgba(238, 247, 242, 0.74);
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
.legend {
|
|
516
|
-
position: absolute;
|
|
517
|
-
left: 50%;
|
|
518
|
-
bottom: 28px;
|
|
519
|
-
transform: translateX(-50%);
|
|
520
|
-
display: flex;
|
|
521
|
-
gap: 20px;
|
|
522
|
-
padding: 8px 16px;
|
|
523
|
-
border-radius: 999px;
|
|
524
|
-
background: rgba(8, 15, 12, 0.82);
|
|
525
|
-
border: 1px solid rgba(158, 202, 176, 0.18);
|
|
526
|
-
font-size: 12px;
|
|
527
|
-
font-weight: 800;
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
.legend span {
|
|
531
|
-
display: inline-flex;
|
|
532
|
-
align-items: center;
|
|
533
|
-
gap: 7px;
|
|
534
|
-
white-space: nowrap;
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
.legend b {
|
|
538
|
-
width: 8px;
|
|
539
|
-
height: 8px;
|
|
540
|
-
border-radius: 999px;
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
.legend .blue {
|
|
544
|
-
background: #3d8cff;
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
.legend .green {
|
|
548
|
-
background: #69f6b1;
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
.legend .yellow {
|
|
552
|
-
background: #eac94f;
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
@media (max-width: 880px) {
|
|
556
|
-
.topbar {
|
|
557
|
-
height: auto;
|
|
558
|
-
align-items: flex-start;
|
|
559
|
-
flex-direction: column;
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
.toolbar {
|
|
563
|
-
flex-wrap: wrap;
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
.mapCanvas {
|
|
567
|
-
min-height: 980px;
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
.serviceNode {
|
|
571
|
-
width: 210px;
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
.sectionLabel {
|
|
575
|
-
align-items: flex-start;
|
|
576
|
-
flex-direction: column;
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
`;
|
|
580
|
-
}
|
|
581
93
|
|
|
582
94
|
function tauriConfig(graph) {
|
|
583
95
|
return `${JSON.stringify({
|
|
@@ -657,11 +169,17 @@ async function main() {
|
|
|
657
169
|
const graph = await readJson(options.input);
|
|
658
170
|
|
|
659
171
|
await mkdir(options.output, { recursive: true });
|
|
172
|
+
await prepareProjectLogo(graph, options.output);
|
|
660
173
|
await writeProjectFile(options.output, 'package.json', packageJson(graph));
|
|
661
174
|
await writeProjectFile(options.output, 'index.html', indexHtml());
|
|
662
175
|
await writeProjectFile(options.output, 'vite.config.js', viteConfig());
|
|
663
|
-
await writeProjectFile(options.output, 'src/main.jsx',
|
|
664
|
-
|
|
176
|
+
await writeProjectFile(options.output, 'src/main.jsx', `import React from 'react';
|
|
177
|
+
import { createRoot } from 'react-dom/client';
|
|
178
|
+
import ServiceMap from './ServiceMap';
|
|
179
|
+
createRoot(document.getElementById('root')).render(<ServiceMap />);
|
|
180
|
+
`);
|
|
181
|
+
await writeProjectFile(options.output, 'src/ServiceMap.tsx', interactiveTsxPage(graph, { desktop: true }));
|
|
182
|
+
await writeProjectFile(options.output, '.gitignore', 'node_modules/\ndist/\nsrc-tauri/target/\n');
|
|
665
183
|
await writeProjectFile(options.output, 'src/servicemap-data.json', `${JSON.stringify(graph, null, 2)}\n`);
|
|
666
184
|
await writeProjectFile(options.output, 'src-tauri/tauri.conf.json', tauriConfig(graph));
|
|
667
185
|
await writeProjectFile(options.output, 'src-tauri/Cargo.toml', cargoToml());
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { spawn } from 'node:child_process';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
5
6
|
import { dirname, extname, isAbsolute, join, resolve } from 'node:path';
|
|
6
7
|
|
|
7
8
|
const defaultInput = '.servicemap/internal-servicemap.json';
|
|
@@ -69,23 +70,29 @@ function pageCss() {
|
|
|
69
70
|
.brand h1 { margin: 0; font-size: 20px; line-height: 1; letter-spacing: 0; }
|
|
70
71
|
.brand p, .top-meta { margin: 6px 0 0; color: #8aa097; font-size: 14px; }
|
|
71
72
|
.top-actions { display: flex; align-items: center; gap: 12px; color: #8da59a; font-size: 14px; white-space: nowrap; }
|
|
73
|
+
.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); }
|
|
72
74
|
.mode-toggle { display: inline-flex; gap: 4px; padding: 4px; border-radius: 10px; background: #0b130f; }
|
|
73
75
|
.mode-button { min-height: 36px; border: 0; border-radius: 8px; background: transparent; color: #789086; padding: 0 12px; cursor: pointer; font: inherit; font-weight: 800; }
|
|
74
76
|
.mode-button.is-active { background: #276f53; color: #c9f9df; box-shadow: 0 0 18px rgba(101,240,173,0.16); }
|
|
75
77
|
.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-
|
|
78
|
+
.map.is-panning { cursor: grabbing; user-select: none; }
|
|
79
|
+
.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; }
|
|
80
|
+
.map.is-panning .map-plane { transition: none; }
|
|
77
81
|
.intro { position: absolute; left: 32px; top: 32px; z-index: 20; }
|
|
78
82
|
.intro strong { display: block; color: #779286; font-size: 12px; letter-spacing: 0.22em; text-transform: uppercase; }
|
|
79
83
|
.intro span { display: block; margin-top: 8px; color: #53665d; font-size: 14px; }
|
|
80
84
|
.edges { position: absolute; inset: 0; z-index: 26; width: 100%; height: 100%; pointer-events: none; }
|
|
81
85
|
.edge-line { transition: opacity 180ms ease, stroke 180ms ease; }
|
|
82
|
-
.edge-line.is-active { stroke: #
|
|
83
|
-
.edge-line.is-muted { opacity: 0.
|
|
86
|
+
.edge-line.is-active { stroke: #7ee2b8; stroke-width: 2.4px; stroke-dasharray: 5 5; animation: servicemapDash .5s linear infinite; filter: drop-shadow(0 0 5px rgba(126,226,184,.55)); opacity: 1; }
|
|
87
|
+
.edge-line.is-muted { opacity: 0.12; }
|
|
84
88
|
.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); }
|
|
85
89
|
.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); }
|
|
86
90
|
.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); }
|
|
87
91
|
button.service-node { cursor: pointer; color: inherit; font: inherit; text-align: left; }
|
|
88
92
|
.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); }
|
|
93
|
+
.map.has-selection .service-node { opacity: 0.16; }
|
|
94
|
+
.map.has-selection .service-node.is-related { opacity: 0.48; }
|
|
95
|
+
.map.has-selection .service-node.is-selected { opacity: 1; }
|
|
89
96
|
.service-node.is-selected { z-index: 27; }
|
|
90
97
|
.logo { width: 48px; height: 48px; flex: 0 0 auto; border-radius: 8px; display: grid; place-items: center; overflow: hidden; color: #fff; font-weight: 700; }
|
|
91
98
|
.logo img { width: 28px; height: 28px; object-fit: contain; filter: invert(1); }
|
|
@@ -94,7 +101,7 @@ function pageCss() {
|
|
|
94
101
|
.service-copy span { display: block; margin-top: 5px; color: #7f9188; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
95
102
|
.status-dot { margin-left: auto; width: 8px; height: 8px; flex: 0 0 auto; border-radius: 999px; box-shadow: 0 0 14px currentColor; }
|
|
96
103
|
.status-high { color: #65f0ad; background: #65f0ad; }
|
|
97
|
-
.status-medium, .status-low { color: #f1c85b; background: #f1c85b; }
|
|
104
|
+
.status-medium, .status-low, .status-manual { color: #f1c85b; background: #f1c85b; }
|
|
98
105
|
.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; }
|
|
99
106
|
.legend i { display: inline-block; width: 8px; height: 8px; margin-right: 8px; border-radius: 999px; }
|
|
100
107
|
.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); }
|
|
@@ -104,7 +111,7 @@ function pageCss() {
|
|
|
104
111
|
.fit-icon { display: inline-block; width: 15px; height: 15px; border: 2px solid currentColor; border-radius: 3px; }
|
|
105
112
|
.scan-foot { position: absolute; right: 32px; bottom: 32px; z-index: 20; color: #53665d; text-align: right; font-size: 12px; line-height: 1.6; }
|
|
106
113
|
.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; }
|
|
107
|
-
.drawer-scrim { position: absolute; inset: 0; z-index:
|
|
114
|
+
.drawer-scrim { position: absolute; inset: 0; z-index: 18; background: rgba(0,0,0,0.58); pointer-events: none; }
|
|
108
115
|
.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); }
|
|
109
116
|
.details-head { display: flex; align-items: center; gap: 16px; padding: 26px 26px 22px; border-bottom: 1px solid rgba(255,255,255,0.08); }
|
|
110
117
|
.details-title { min-width: 0; flex: 1; }
|
|
@@ -129,6 +136,12 @@ function pageCss() {
|
|
|
129
136
|
.delete-button { color: #ff6b57; background: transparent; font-weight: 800; }
|
|
130
137
|
.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); }
|
|
131
138
|
.helper-text { margin: 10px 0 0; color: #687c72; font-size: 12px; line-height: 1.45; }
|
|
139
|
+
.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; }
|
|
140
|
+
.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); }
|
|
141
|
+
.modal-head h2 { margin: 0; color: #f4fbf6; font-size: 20px; letter-spacing: 0; }
|
|
142
|
+
.modal-body { padding: 22px 24px 24px; }
|
|
143
|
+
.modal-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 22px; }
|
|
144
|
+
.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; }
|
|
132
145
|
.graph-3d { position: absolute; inset: 0; overflow: hidden; }
|
|
133
146
|
.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
147
|
.graph-3d canvas { display: block; cursor: grab; }
|
|
@@ -142,12 +155,15 @@ function pageCss() {
|
|
|
142
155
|
.graph-3d__zoom button + button { border-top: 1px solid rgba(255,255,255,.08); }
|
|
143
156
|
.graph-3d__zoom button:hover { color: #fff; background: rgba(255,255,255,.08); }
|
|
144
157
|
.graph-3d__loading { position: absolute; inset: 0; display: grid; place-items: center; color: #75857c; font-size: 9px; }
|
|
145
|
-
@keyframes servicemapDash { to { stroke-dashoffset:
|
|
158
|
+
@keyframes servicemapDash { from { stroke-dashoffset: 10; } to { stroke-dashoffset: 0; } }
|
|
159
|
+
@media (prefers-reduced-motion: reduce) { .edge-line.is-active { animation: none; } }
|
|
146
160
|
@keyframes servicemapLinkFlow { to { background-position: 22px 0; } }
|
|
147
161
|
@media (max-width: 920px) {
|
|
148
162
|
.map { min-height: 1120px; }
|
|
149
163
|
.service-node { width: 220px; }
|
|
150
|
-
.
|
|
164
|
+
.servicemap-topbar { height: auto; min-height: 88px; flex-wrap: wrap; padding: 14px; }
|
|
165
|
+
.top-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
166
|
+
.top-actions > span { display: none; }
|
|
151
167
|
.details-panel { position: fixed; inset: auto 12px 12px; top: 110px; width: auto; }
|
|
152
168
|
}
|
|
153
169
|
`;
|
|
@@ -259,20 +275,21 @@ ${htmlBody(graph)}
|
|
|
259
275
|
`;
|
|
260
276
|
}
|
|
261
277
|
|
|
262
|
-
function interactiveTsxPage(graph) {
|
|
278
|
+
export function interactiveTsxPage(graph, { desktop = false } = {}) {
|
|
263
279
|
return `'use client';
|
|
264
280
|
|
|
265
|
-
import dynamic from 'next/dynamic';
|
|
266
|
-
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
|
281
|
+
${desktop ? "import ForceGraph3D from 'react-force-graph-3d';" : "import dynamic from 'next/dynamic';"}
|
|
282
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent } from 'react';
|
|
267
283
|
import type { ForceGraphMethods, NodeObject } from 'react-force-graph-3d';
|
|
268
284
|
import * as THREE from 'three';
|
|
269
285
|
import SpriteText from 'three-spritetext';
|
|
270
286
|
|
|
271
287
|
const css = ${JSON.stringify(pageCss())};
|
|
272
288
|
type Evidence = { type?: string; value?: string; confidence?: string };
|
|
273
|
-
type RawService = { id: string; name: string; key?: string; category?: string; confidence?: string; evidence?: Evidence[]; iconUrl?: string | null };
|
|
289
|
+
type RawService = { id: string; name: string; key?: string; category?: string; confidence?: string; evidence?: Evidence[]; iconUrl?: string | null; parentId?: string | null };
|
|
274
290
|
type ServiceNode = RawService & { color: string; x: number; y: number };
|
|
275
291
|
type DraftDetails = { subtitle: string; use: string; account: string; passwordLocation: string };
|
|
292
|
+
type NewServiceDraft = { name: string; category: string; parentId: string };
|
|
276
293
|
type ServiceLink = { label: string; url: string };
|
|
277
294
|
type GraphNode3D = { id: string; name: string; category: string; color: string; iconUrl?: string | null; monogram: string; isProduct: boolean; x?: number; y?: number; z?: number };
|
|
278
295
|
type GraphLink3D = { id: string; source: string | GraphNode3D; target: string | GraphNode3D; label: string };
|
|
@@ -282,7 +299,7 @@ type OrbitControlsLike = { target?: THREE.Vector3 };
|
|
|
282
299
|
const graph = ${JSON.stringify(graph)} as { generatedAt: string; project?: { name?: string; logoUrl?: string }; services?: RawService[] };
|
|
283
300
|
const palette = ${JSON.stringify(palette)} as string[];
|
|
284
301
|
const layout = ${JSON.stringify(layout)} as Array<{ x: number; y: number }>;
|
|
285
|
-
const ForceGraph3D = dynamic(() => import('react-force-graph-3d'), { ssr: false });
|
|
302
|
+
${desktop ? '' : "const ForceGraph3D = dynamic(() => import('react-force-graph-3d'), { ssr: false });"}
|
|
286
303
|
|
|
287
304
|
function categoryLabel(value?: string | null) {
|
|
288
305
|
return String(value ?? 'service').replace(/-/g, ' ');
|
|
@@ -292,12 +309,36 @@ function initials(name: string) {
|
|
|
292
309
|
return name.split(/\\s+/).filter(Boolean).slice(0, 2).map((part) => part[0]).join('').toUpperCase();
|
|
293
310
|
}
|
|
294
311
|
|
|
295
|
-
function
|
|
296
|
-
const
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
312
|
+
function Connections({ nodes, selectedId }: { nodes: ServiceNode[]; selectedId: string | null }) {
|
|
313
|
+
const ref = useRef<SVGSVGElement>(null);
|
|
314
|
+
const [size, setSize] = useState({ width: 0, height: 0, nodeHalfWidth: 125 });
|
|
315
|
+
useEffect(() => {
|
|
316
|
+
if (!ref.current) return;
|
|
317
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
318
|
+
const node = ref.current?.parentElement?.querySelector('.service-node');
|
|
319
|
+
const nodeHalfWidth = node ? parseFloat(getComputedStyle(node).width) / 2 : 125;
|
|
320
|
+
setSize({ width: entry.contentRect.width, height: entry.contentRect.height, nodeHalfWidth });
|
|
321
|
+
});
|
|
322
|
+
observer.observe(ref.current);
|
|
323
|
+
return () => observer.disconnect();
|
|
324
|
+
}, []);
|
|
325
|
+
return <svg ref={ref} className="edges" aria-hidden="true">
|
|
326
|
+
{size.width > 0 && nodes.map((node) => {
|
|
327
|
+
const parent = nodes.find((candidate) => candidate.id === node.parentId);
|
|
328
|
+
const direction = node.x < (parent?.x ?? 50) ? -1 : 1;
|
|
329
|
+
const x1 = (parent?.x ?? 50) * size.width / 100 + direction * (parent ? size.nodeHalfWidth : 66);
|
|
330
|
+
const y1 = (parent?.y ?? 50) * size.height / 100;
|
|
331
|
+
const x2 = node.x * size.width / 100 - direction * size.nodeHalfWidth;
|
|
332
|
+
const y2 = node.y * size.height / 100;
|
|
333
|
+
const middle = (x1 + x2) / 2;
|
|
334
|
+
const dx = Math.sign(x2 - x1);
|
|
335
|
+
const dy = Math.sign(y2 - y1);
|
|
336
|
+
const radius = Math.min(5, Math.abs(x2 - x1) / 4, Math.abs(y2 - y1) / 2);
|
|
337
|
+
const path = \`M \${x1} \${y1} H \${middle - dx * radius} Q \${middle} \${y1} \${middle} \${y1 + dy * radius} V \${y2 - dy * radius} Q \${middle} \${y2} \${middle + dx * radius} \${y2} H \${x2}\`;
|
|
338
|
+
const active = selectedId === node.id || selectedId === node.parentId;
|
|
339
|
+
return <path key={node.id} d={path} fill="none" stroke="rgba(103,130,118,.48)" strokeWidth="1.2" className={\`edge-line \${active ? 'is-active' : selectedId ? 'is-muted' : ''}\`} />;
|
|
340
|
+
})}
|
|
341
|
+
</svg>;
|
|
301
342
|
}
|
|
302
343
|
|
|
303
344
|
function primaryEvidence(service: RawService) {
|
|
@@ -317,6 +358,25 @@ function graphLink(link: GraphLink3D) {
|
|
|
317
358
|
return link;
|
|
318
359
|
}
|
|
319
360
|
|
|
361
|
+
function slugify(value: string) {
|
|
362
|
+
return value.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'service';
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function servicePosition(index: number) {
|
|
366
|
+
const point = layout[index];
|
|
367
|
+
if (point) return point;
|
|
368
|
+
const angle = (index - layout.length) * 2.399963229728653;
|
|
369
|
+
const radius = 34 + ((index - layout.length) % 4) * 4;
|
|
370
|
+
return {
|
|
371
|
+
x: 50 + Math.cos(angle) * radius,
|
|
372
|
+
y: 50 + Math.sin(angle) * radius * 0.72,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function relationshipLabel(parentId?: string | null) {
|
|
377
|
+
return parentId ? 'depends on' : 'uses';
|
|
378
|
+
}
|
|
379
|
+
|
|
320
380
|
function iconObject(node: GraphNode3D, selected: boolean) {
|
|
321
381
|
const group = new THREE.Group();
|
|
322
382
|
const radius = node.isProduct ? 10 : 7;
|
|
@@ -367,7 +427,7 @@ function iconObject(node: GraphNode3D, selected: boolean) {
|
|
|
367
427
|
|
|
368
428
|
const label = new SpriteText(node.name);
|
|
369
429
|
label.color = selected ? '#b9f6da' : '#dfeae4';
|
|
370
|
-
label.textHeight = selected ?
|
|
430
|
+
label.textHeight = selected ? 4.1 : 3.2;
|
|
371
431
|
label.fontSize = 220;
|
|
372
432
|
label.fontWeight = selected ? '700' : '600';
|
|
373
433
|
label.backgroundColor = 'rgba(5, 8, 7, .72)';
|
|
@@ -511,6 +571,70 @@ function DetailsPanel({
|
|
|
511
571
|
);
|
|
512
572
|
}
|
|
513
573
|
|
|
574
|
+
function AddServiceModal({
|
|
575
|
+
draft,
|
|
576
|
+
nodes,
|
|
577
|
+
onChange,
|
|
578
|
+
onClose,
|
|
579
|
+
onSubmit,
|
|
580
|
+
}: {
|
|
581
|
+
draft: NewServiceDraft;
|
|
582
|
+
nodes: ServiceNode[];
|
|
583
|
+
onChange: (draft: NewServiceDraft) => void;
|
|
584
|
+
onClose: () => void;
|
|
585
|
+
onSubmit: () => void;
|
|
586
|
+
}) {
|
|
587
|
+
return (
|
|
588
|
+
<>
|
|
589
|
+
<div className="drawer-scrim" />
|
|
590
|
+
<section className="modal-card" aria-label="Add service">
|
|
591
|
+
<div className="modal-head">
|
|
592
|
+
<h2>Add service</h2>
|
|
593
|
+
<button type="button" className="close-button" onClick={onClose} aria-label="Close add service">×</button>
|
|
594
|
+
</div>
|
|
595
|
+
<div className="modal-body">
|
|
596
|
+
<label className="field-label" htmlFor="new-service-name">Service name</label>
|
|
597
|
+
<input
|
|
598
|
+
id="new-service-name"
|
|
599
|
+
className="details-input"
|
|
600
|
+
value={draft.name}
|
|
601
|
+
onChange={(event) => onChange({ ...draft, name: event.target.value })}
|
|
602
|
+
placeholder="Stripe, Vercel, Google Cloud"
|
|
603
|
+
autoFocus
|
|
604
|
+
/>
|
|
605
|
+
|
|
606
|
+
<label className="field-label" htmlFor="new-service-category">Use</label>
|
|
607
|
+
<input
|
|
608
|
+
id="new-service-category"
|
|
609
|
+
className="details-input"
|
|
610
|
+
value={draft.category}
|
|
611
|
+
onChange={(event) => onChange({ ...draft, category: event.target.value })}
|
|
612
|
+
placeholder="billing, hosting, analytics"
|
|
613
|
+
/>
|
|
614
|
+
|
|
615
|
+
<label className="field-label" htmlFor="new-service-parent">Parent node</label>
|
|
616
|
+
<select
|
|
617
|
+
id="new-service-parent"
|
|
618
|
+
className="details-input"
|
|
619
|
+
value={draft.parentId}
|
|
620
|
+
onChange={(event) => onChange({ ...draft, parentId: event.target.value })}
|
|
621
|
+
>
|
|
622
|
+
<option value="product">{graph.project?.name ?? 'Product'}</option>
|
|
623
|
+
{nodes.map((node) => (
|
|
624
|
+
<option key={node.id} value={node.id}>{node.name}</option>
|
|
625
|
+
))}
|
|
626
|
+
</select>
|
|
627
|
+
|
|
628
|
+
<div className="modal-actions">
|
|
629
|
+
<button type="button" className="secondary-button" onClick={onClose}>Cancel</button>
|
|
630
|
+
<button type="button" className="save-button" onClick={onSubmit}>Add service</button>
|
|
631
|
+
</div>
|
|
632
|
+
</div>
|
|
633
|
+
</section>
|
|
634
|
+
</>
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
|
|
514
638
|
function ServiceGraph3D({
|
|
515
639
|
nodes,
|
|
516
640
|
selectedId,
|
|
@@ -524,6 +648,7 @@ function ServiceGraph3D({
|
|
|
524
648
|
const graphRef = useRef<ForceGraphMethods<GraphNode3D, GraphLink3D> | undefined>(undefined);
|
|
525
649
|
const [size, setSize] = useState({ width: 900, height: 700 });
|
|
526
650
|
const hasFramed = useRef(false);
|
|
651
|
+
const hasConfiguredForces = useRef(false);
|
|
527
652
|
const graphData = useMemo(() => ({
|
|
528
653
|
nodes: [{
|
|
529
654
|
id: 'project',
|
|
@@ -543,10 +668,10 @@ function ServiceGraph3D({
|
|
|
543
668
|
isProduct: node.category === 'product',
|
|
544
669
|
}))],
|
|
545
670
|
links: nodes.map((node): GraphLink3D => ({
|
|
546
|
-
id:
|
|
547
|
-
source: 'project',
|
|
671
|
+
id: \`\${node.parentId ?? 'project'}-\${node.id}\`,
|
|
672
|
+
source: node.parentId ?? 'project',
|
|
548
673
|
target: node.id,
|
|
549
|
-
label: node.confidence === 'high' ?
|
|
674
|
+
label: node.confidence === 'high' ? relationshipLabel(node.parentId) : \`may \${relationshipLabel(node.parentId)}\`,
|
|
550
675
|
})),
|
|
551
676
|
}), [nodes]);
|
|
552
677
|
|
|
@@ -573,17 +698,28 @@ function ServiceGraph3D({
|
|
|
573
698
|
return () => observer.disconnect();
|
|
574
699
|
}, []);
|
|
575
700
|
|
|
576
|
-
|
|
701
|
+
const configureForces = useCallback(() => {
|
|
577
702
|
const graphInstance = graphRef.current;
|
|
578
|
-
if (!graphInstance) return;
|
|
703
|
+
if (!graphInstance || hasConfiguredForces.current) return;
|
|
579
704
|
const charge = graphInstance.d3Force('charge') as AdjustableForce | undefined;
|
|
580
705
|
const link = graphInstance.d3Force('link') as AdjustableForce | undefined;
|
|
581
706
|
graphInstance.renderer().setPixelRatio(Math.min(window.devicePixelRatio * 1.5, 3));
|
|
582
|
-
charge?.strength?.(-
|
|
583
|
-
link?.distance?.(
|
|
707
|
+
charge?.strength?.(-720);
|
|
708
|
+
link?.distance?.(210);
|
|
584
709
|
graphInstance.d3ReheatSimulation();
|
|
710
|
+
hasConfiguredForces.current = true;
|
|
711
|
+
}, []);
|
|
712
|
+
|
|
713
|
+
useEffect(() => {
|
|
585
714
|
hasFramed.current = false;
|
|
586
|
-
|
|
715
|
+
hasConfiguredForces.current = false;
|
|
716
|
+
const frame = window.requestAnimationFrame(configureForces);
|
|
717
|
+
const initialFit = window.setTimeout(() => graphRef.current?.zoomToFit(400, 100), 350);
|
|
718
|
+
return () => {
|
|
719
|
+
window.cancelAnimationFrame(frame);
|
|
720
|
+
window.clearTimeout(initialFit);
|
|
721
|
+
};
|
|
722
|
+
}, [configureForces, graphData.nodes.length, graphData.links.length]);
|
|
587
723
|
|
|
588
724
|
const focusNode = useCallback((node: NodeObject) => {
|
|
589
725
|
const serviceNode = graphNode(node);
|
|
@@ -653,16 +789,17 @@ function ServiceGraph3D({
|
|
|
653
789
|
linkDirectionalParticleWidth={1.8}
|
|
654
790
|
linkDirectionalParticleSpeed={0.006}
|
|
655
791
|
linkDirectionalParticleColor={() => '#b9f6da'}
|
|
656
|
-
d3AlphaDecay={0.
|
|
657
|
-
d3VelocityDecay={0.
|
|
658
|
-
warmupTicks={
|
|
659
|
-
cooldownTicks={
|
|
792
|
+
d3AlphaDecay={0.026}
|
|
793
|
+
d3VelocityDecay={0.18}
|
|
794
|
+
warmupTicks={90}
|
|
795
|
+
cooldownTicks={260}
|
|
660
796
|
onNodeClick={focusNode}
|
|
661
797
|
onBackgroundClick={() => onSelect(null)}
|
|
798
|
+
onEngineTick={configureForces}
|
|
662
799
|
onEngineStop={() => {
|
|
663
800
|
if (hasFramed.current) return;
|
|
664
801
|
hasFramed.current = true;
|
|
665
|
-
graphRef.current?.zoomToFit(700,
|
|
802
|
+
graphRef.current?.zoomToFit(700, 130);
|
|
666
803
|
}}
|
|
667
804
|
/>
|
|
668
805
|
<div className="graph-3d__zoom" aria-label="3D zoom controls">
|
|
@@ -676,14 +813,19 @@ function ServiceGraph3D({
|
|
|
676
813
|
}
|
|
677
814
|
|
|
678
815
|
export default function InternalServiceMapPage() {
|
|
679
|
-
const
|
|
816
|
+
const [services, setServices] = useState<RawService[]>(() => graph.services ?? []);
|
|
817
|
+
const nodes = useMemo<ServiceNode[]>(() => services.map((service, index) => ({
|
|
680
818
|
...service,
|
|
681
|
-
...
|
|
819
|
+
...servicePosition(index),
|
|
682
820
|
color: palette[index % palette.length],
|
|
683
|
-
})), []);
|
|
821
|
+
})), [services]);
|
|
684
822
|
const [mode, setMode] = useState<'2d' | '3d'>('2d');
|
|
685
823
|
const [zoom, setZoom] = useState(1);
|
|
824
|
+
const [pan, setPan] = useState({ x: 0, y: 0 });
|
|
825
|
+
const [panStart, setPanStart] = useState<{ pointerId: number; x: number; y: number; panX: number; panY: number } | null>(null);
|
|
686
826
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
827
|
+
const [isAddingService, setIsAddingService] = useState(false);
|
|
828
|
+
const [newService, setNewService] = useState<NewServiceDraft>({ name: '', category: '', parentId: 'product' });
|
|
687
829
|
const [drafts, setDrafts] = useState<Record<string, DraftDetails>>(() => Object.fromEntries(nodes.map((service) => [
|
|
688
830
|
service.id,
|
|
689
831
|
{
|
|
@@ -696,7 +838,61 @@ export default function InternalServiceMapPage() {
|
|
|
696
838
|
const selected = nodes.find((service) => service.id === selectedId) ?? null;
|
|
697
839
|
const zoomIn = () => setZoom((current) => Math.min(1.45, Number((current + 0.12).toFixed(2))));
|
|
698
840
|
const zoomOut = () => setZoom((current) => Math.max(0.7, Number((current - 0.12).toFixed(2))));
|
|
699
|
-
const resetZoom = () =>
|
|
841
|
+
const resetZoom = () => {
|
|
842
|
+
setZoom(1);
|
|
843
|
+
setPan({ x: 0, y: 0 });
|
|
844
|
+
};
|
|
845
|
+
const startPan = (event: PointerEvent<HTMLElement>) => {
|
|
846
|
+
if (mode !== '2d' || event.button !== 0 || (event.target as HTMLElement).closest('button, a, input, textarea, select')) return;
|
|
847
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
848
|
+
setPanStart({ pointerId: event.pointerId, x: event.clientX, y: event.clientY, panX: pan.x, panY: pan.y });
|
|
849
|
+
};
|
|
850
|
+
const movePan = (event: PointerEvent<HTMLElement>) => {
|
|
851
|
+
if (!panStart || panStart.pointerId !== event.pointerId) return;
|
|
852
|
+
setPan({
|
|
853
|
+
x: panStart.panX + event.clientX - panStart.x,
|
|
854
|
+
y: panStart.panY + event.clientY - panStart.y,
|
|
855
|
+
});
|
|
856
|
+
};
|
|
857
|
+
const stopPan = (event: PointerEvent<HTMLElement>) => {
|
|
858
|
+
if (panStart?.pointerId !== event.pointerId) return;
|
|
859
|
+
setPanStart(null);
|
|
860
|
+
};
|
|
861
|
+
const addService = () => {
|
|
862
|
+
const name = newService.name.trim();
|
|
863
|
+
if (!name) return;
|
|
864
|
+
const baseId = slugify(name);
|
|
865
|
+
const existingIds = new Set(services.map((service) => service.id));
|
|
866
|
+
let id = baseId;
|
|
867
|
+
let suffix = 2;
|
|
868
|
+
while (existingIds.has(id)) {
|
|
869
|
+
id = \`\${baseId}-\${suffix}\`;
|
|
870
|
+
suffix += 1;
|
|
871
|
+
}
|
|
872
|
+
const service: RawService = {
|
|
873
|
+
id,
|
|
874
|
+
key: id,
|
|
875
|
+
name,
|
|
876
|
+
category: newService.category.trim() || 'service',
|
|
877
|
+
confidence: 'manual',
|
|
878
|
+
evidence: [{ type: 'manual', value: 'Added in servicemap UI.', confidence: 'manual' }],
|
|
879
|
+
iconUrl: null,
|
|
880
|
+
parentId: newService.parentId === 'product' ? null : newService.parentId,
|
|
881
|
+
};
|
|
882
|
+
setServices((current) => [...current, service]);
|
|
883
|
+
setDrafts((current) => ({
|
|
884
|
+
...current,
|
|
885
|
+
[id]: {
|
|
886
|
+
subtitle: categoryLabel(service.category),
|
|
887
|
+
use: primaryEvidence(service),
|
|
888
|
+
account: '',
|
|
889
|
+
passwordLocation: '',
|
|
890
|
+
},
|
|
891
|
+
}));
|
|
892
|
+
setSelectedId(id);
|
|
893
|
+
setNewService({ name: '', category: '', parentId: 'product' });
|
|
894
|
+
setIsAddingService(false);
|
|
895
|
+
};
|
|
700
896
|
|
|
701
897
|
return (
|
|
702
898
|
<main className="servicemap-shell">
|
|
@@ -711,6 +907,7 @@ export default function InternalServiceMapPage() {
|
|
|
711
907
|
</div>
|
|
712
908
|
<div className="top-actions">
|
|
713
909
|
<span>{nodes.length} services</span>
|
|
910
|
+
<button type="button" className="add-service-button" onClick={() => setIsAddingService(true)}>+ Add service</button>
|
|
714
911
|
<div className="mode-toggle" aria-label="View mode">
|
|
715
912
|
<button type="button" className={\`mode-button \${mode === '2d' ? 'is-active' : ''}\`} onClick={() => setMode('2d')}>2D</button>
|
|
716
913
|
<button type="button" className={\`mode-button \${mode === '3d' ? 'is-active' : ''}\`} onClick={() => setMode('3d')}>3D</button>
|
|
@@ -718,35 +915,29 @@ export default function InternalServiceMapPage() {
|
|
|
718
915
|
</div>
|
|
719
916
|
</header>
|
|
720
917
|
|
|
721
|
-
<section
|
|
918
|
+
<section
|
|
919
|
+
className={\`map \${panStart ? 'is-panning' : ''} \${selectedId ? 'has-selection' : ''}\`}
|
|
920
|
+
onPointerDown={startPan}
|
|
921
|
+
onPointerMove={movePan}
|
|
922
|
+
onPointerUp={stopPan}
|
|
923
|
+
onPointerCancel={stopPan}
|
|
924
|
+
>
|
|
722
925
|
<div className="intro">
|
|
723
926
|
<strong>{graph.project?.name ?? 'Project'} infrastructure</strong>
|
|
724
927
|
<span>{selected ? \`Tracing \${selected.name}\` : mode === '3d' ? 'Rotate and explore your service relationships' : 'Select a service to trace its dependencies'}</span>
|
|
725
928
|
</div>
|
|
726
929
|
{mode === '2d' ? (
|
|
727
930
|
<>
|
|
728
|
-
<div className="map-plane" style={{ '--zoom': zoom } as CSSProperties}>
|
|
729
|
-
<
|
|
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>
|
|
931
|
+
<div className="map-plane" style={{ '--zoom': zoom, '--pan-x': \`\${pan.x}px\`, '--pan-y': \`\${pan.y}px\` } as CSSProperties}>
|
|
932
|
+
<Connections nodes={nodes} selectedId={selectedId} />
|
|
744
933
|
<div className="hub"><div className="hub-inner"><ProjectMark /></div></div>
|
|
745
|
-
{nodes.map((service) =>
|
|
934
|
+
{nodes.map((service) => {
|
|
935
|
+
const isRelated = selectedId === service.parentId || selected?.parentId === service.id;
|
|
936
|
+
return (
|
|
746
937
|
<button
|
|
747
938
|
type="button"
|
|
748
939
|
key={service.id}
|
|
749
|
-
className={\`service-node \${selectedId === service.id ? 'is-selected' : ''}\`}
|
|
940
|
+
className={\`service-node \${selectedId === service.id ? 'is-selected' : ''} \${isRelated ? 'is-related' : ''}\`}
|
|
750
941
|
style={{ left: \`\${service.x}%\`, top: \`\${service.y}%\` }}
|
|
751
942
|
onClick={() => setSelectedId(service.id)}
|
|
752
943
|
>
|
|
@@ -757,7 +948,8 @@ export default function InternalServiceMapPage() {
|
|
|
757
948
|
</div>
|
|
758
949
|
<i className={\`status-dot status-\${service.confidence}\`} />
|
|
759
950
|
</button>
|
|
760
|
-
|
|
951
|
+
);
|
|
952
|
+
})}
|
|
761
953
|
</div>
|
|
762
954
|
<div className="legend">
|
|
763
955
|
<span><i style={{ background: '#4a7cff' }} />Cloud</span>
|
|
@@ -785,6 +977,15 @@ export default function InternalServiceMapPage() {
|
|
|
785
977
|
onChange={(nextDetails) => setDrafts((current) => ({ ...current, [selected.id]: nextDetails }))}
|
|
786
978
|
/>
|
|
787
979
|
) : null}
|
|
980
|
+
{isAddingService ? (
|
|
981
|
+
<AddServiceModal
|
|
982
|
+
draft={newService}
|
|
983
|
+
nodes={nodes}
|
|
984
|
+
onChange={setNewService}
|
|
985
|
+
onClose={() => setIsAddingService(false)}
|
|
986
|
+
onSubmit={addService}
|
|
987
|
+
/>
|
|
988
|
+
) : null}
|
|
788
989
|
</section>
|
|
789
990
|
</main>
|
|
790
991
|
);
|
|
@@ -844,7 +1045,7 @@ const html = ${JSON.stringify(htmlBody(graph))};
|
|
|
844
1045
|
`;
|
|
845
1046
|
}
|
|
846
1047
|
|
|
847
|
-
async function prepareProjectLogo(graph, outputRoot) {
|
|
1048
|
+
export async function prepareProjectLogo(graph, outputRoot) {
|
|
848
1049
|
const logoPath = graph.project?.logo?.path;
|
|
849
1050
|
if (!logoPath) return;
|
|
850
1051
|
|
|
@@ -922,7 +1123,7 @@ async function main() {
|
|
|
922
1123
|
console.log(`Wrote /internalservicemap page to ${route.path}`);
|
|
923
1124
|
}
|
|
924
1125
|
|
|
925
|
-
main().catch((error) => {
|
|
1126
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) main().catch((error) => {
|
|
926
1127
|
console.error(error);
|
|
927
1128
|
process.exit(1);
|
|
928
1129
|
});
|