servicemap 0.1.38 → 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';
|
|
@@ -82,7 +83,7 @@ function pageCss() {
|
|
|
82
83
|
.intro span { display: block; margin-top: 8px; color: #53665d; font-size: 14px; }
|
|
83
84
|
.edges { position: absolute; inset: 0; z-index: 26; width: 100%; height: 100%; pointer-events: none; }
|
|
84
85
|
.edge-line { transition: opacity 180ms ease, stroke 180ms ease; }
|
|
85
|
-
.edge-line.is-active { stroke: #
|
|
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; }
|
|
86
87
|
.edge-line.is-muted { opacity: 0.12; }
|
|
87
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); }
|
|
88
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); }
|
|
@@ -154,12 +155,15 @@ function pageCss() {
|
|
|
154
155
|
.graph-3d__zoom button + button { border-top: 1px solid rgba(255,255,255,.08); }
|
|
155
156
|
.graph-3d__zoom button:hover { color: #fff; background: rgba(255,255,255,.08); }
|
|
156
157
|
.graph-3d__loading { position: absolute; inset: 0; display: grid; place-items: center; color: #75857c; font-size: 9px; }
|
|
157
|
-
@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; } }
|
|
158
160
|
@keyframes servicemapLinkFlow { to { background-position: 22px 0; } }
|
|
159
161
|
@media (max-width: 920px) {
|
|
160
162
|
.map { min-height: 1120px; }
|
|
161
163
|
.service-node { width: 220px; }
|
|
162
|
-
.
|
|
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; }
|
|
163
167
|
.details-panel { position: fixed; inset: auto 12px 12px; top: 110px; width: auto; }
|
|
164
168
|
}
|
|
165
169
|
`;
|
|
@@ -271,10 +275,10 @@ ${htmlBody(graph)}
|
|
|
271
275
|
`;
|
|
272
276
|
}
|
|
273
277
|
|
|
274
|
-
function interactiveTsxPage(graph) {
|
|
278
|
+
export function interactiveTsxPage(graph, { desktop = false } = {}) {
|
|
275
279
|
return `'use client';
|
|
276
280
|
|
|
277
|
-
import dynamic from 'next/dynamic';
|
|
281
|
+
${desktop ? "import ForceGraph3D from 'react-force-graph-3d';" : "import dynamic from 'next/dynamic';"}
|
|
278
282
|
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent } from 'react';
|
|
279
283
|
import type { ForceGraphMethods, NodeObject } from 'react-force-graph-3d';
|
|
280
284
|
import * as THREE from 'three';
|
|
@@ -295,7 +299,7 @@ type OrbitControlsLike = { target?: THREE.Vector3 };
|
|
|
295
299
|
const graph = ${JSON.stringify(graph)} as { generatedAt: string; project?: { name?: string; logoUrl?: string }; services?: RawService[] };
|
|
296
300
|
const palette = ${JSON.stringify(palette)} as string[];
|
|
297
301
|
const layout = ${JSON.stringify(layout)} as Array<{ x: number; y: number }>;
|
|
298
|
-
const ForceGraph3D = dynamic(() => import('react-force-graph-3d'), { ssr: false });
|
|
302
|
+
${desktop ? '' : "const ForceGraph3D = dynamic(() => import('react-force-graph-3d'), { ssr: false });"}
|
|
299
303
|
|
|
300
304
|
function categoryLabel(value?: string | null) {
|
|
301
305
|
return String(value ?? 'service').replace(/-/g, ' ');
|
|
@@ -305,17 +309,36 @@ function initials(name: string) {
|
|
|
305
309
|
return name.split(/\\s+/).filter(Boolean).slice(0, 2).map((part) => part[0]).join('').toUpperCase();
|
|
306
310
|
}
|
|
307
311
|
|
|
308
|
-
function
|
|
309
|
-
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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>;
|
|
319
342
|
}
|
|
320
343
|
|
|
321
344
|
function primaryEvidence(service: RawService) {
|
|
@@ -691,7 +714,11 @@ function ServiceGraph3D({
|
|
|
691
714
|
hasFramed.current = false;
|
|
692
715
|
hasConfiguredForces.current = false;
|
|
693
716
|
const frame = window.requestAnimationFrame(configureForces);
|
|
694
|
-
|
|
717
|
+
const initialFit = window.setTimeout(() => graphRef.current?.zoomToFit(400, 100), 350);
|
|
718
|
+
return () => {
|
|
719
|
+
window.cancelAnimationFrame(frame);
|
|
720
|
+
window.clearTimeout(initialFit);
|
|
721
|
+
};
|
|
695
722
|
}, [configureForces, graphData.nodes.length, graphData.links.length]);
|
|
696
723
|
|
|
697
724
|
const focusNode = useCallback((node: NodeObject) => {
|
|
@@ -809,7 +836,6 @@ export default function InternalServiceMapPage() {
|
|
|
809
836
|
},
|
|
810
837
|
])));
|
|
811
838
|
const selected = nodes.find((service) => service.id === selectedId) ?? null;
|
|
812
|
-
const nodeById = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]);
|
|
813
839
|
const zoomIn = () => setZoom((current) => Math.min(1.45, Number((current + 0.12).toFixed(2))));
|
|
814
840
|
const zoomOut = () => setZoom((current) => Math.max(0.7, Number((current - 0.12).toFixed(2))));
|
|
815
841
|
const resetZoom = () => {
|
|
@@ -903,25 +929,7 @@ export default function InternalServiceMapPage() {
|
|
|
903
929
|
{mode === '2d' ? (
|
|
904
930
|
<>
|
|
905
931
|
<div className="map-plane" style={{ '--zoom': zoom, '--pan-x': \`\${pan.x}px\`, '--pan-y': \`\${pan.y}px\` } as CSSProperties}>
|
|
906
|
-
<
|
|
907
|
-
{nodes.map((service) => {
|
|
908
|
-
const parent = service.parentId ? nodeById.get(service.parentId) : null;
|
|
909
|
-
const isActive = selectedId === service.id || selectedId === service.parentId;
|
|
910
|
-
return (
|
|
911
|
-
<path
|
|
912
|
-
key={service.id}
|
|
913
|
-
className={\`edge-line \${isActive ? 'is-active' : selectedId ? 'is-muted' : ''}\`}
|
|
914
|
-
d={parent ? edgePathBetween(parent, service) : edgePath(service)}
|
|
915
|
-
fill="none"
|
|
916
|
-
stroke={isActive ? '#65f0ad' : 'rgba(103, 130, 118, 0.48)'}
|
|
917
|
-
strokeWidth={isActive ? '3' : '0.16'}
|
|
918
|
-
strokeDasharray={isActive ? '10 8' : undefined}
|
|
919
|
-
strokeLinecap={isActive ? 'round' : undefined}
|
|
920
|
-
vectorEffect="non-scaling-stroke"
|
|
921
|
-
/>
|
|
922
|
-
);
|
|
923
|
-
})}
|
|
924
|
-
</svg>
|
|
932
|
+
<Connections nodes={nodes} selectedId={selectedId} />
|
|
925
933
|
<div className="hub"><div className="hub-inner"><ProjectMark /></div></div>
|
|
926
934
|
{nodes.map((service) => {
|
|
927
935
|
const isRelated = selectedId === service.parentId || selected?.parentId === service.id;
|
|
@@ -1037,7 +1045,7 @@ const html = ${JSON.stringify(htmlBody(graph))};
|
|
|
1037
1045
|
`;
|
|
1038
1046
|
}
|
|
1039
1047
|
|
|
1040
|
-
async function prepareProjectLogo(graph, outputRoot) {
|
|
1048
|
+
export async function prepareProjectLogo(graph, outputRoot) {
|
|
1041
1049
|
const logoPath = graph.project?.logo?.path;
|
|
1042
1050
|
if (!logoPath) return;
|
|
1043
1051
|
|
|
@@ -1115,7 +1123,7 @@ async function main() {
|
|
|
1115
1123
|
console.log(`Wrote /internalservicemap page to ${route.path}`);
|
|
1116
1124
|
}
|
|
1117
1125
|
|
|
1118
|
-
main().catch((error) => {
|
|
1126
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) main().catch((error) => {
|
|
1119
1127
|
console.error(error);
|
|
1120
1128
|
process.exit(1);
|
|
1121
1129
|
});
|