servicemap 0.1.26 → 0.1.28
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 +1 -1
- package/servicemap/scripts/generate-page.mjs +360 -14
package/package.json
CHANGED
|
@@ -59,7 +59,9 @@ function pageCss() {
|
|
|
59
59
|
.brand h1 { margin: 0; font-size: 20px; line-height: 1; letter-spacing: 0; }
|
|
60
60
|
.brand p, .top-meta { margin: 6px 0 0; color: #8aa097; font-size: 14px; }
|
|
61
61
|
.top-actions { display: flex; align-items: center; gap: 12px; color: #8da59a; font-size: 14px; white-space: nowrap; }
|
|
62
|
-
.mode-
|
|
62
|
+
.mode-toggle { display: inline-flex; gap: 4px; padding: 4px; border-radius: 10px; background: #0b130f; }
|
|
63
|
+
.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
|
+
.mode-button.is-active { background: #276f53; color: #c9f9df; box-shadow: 0 0 18px rgba(101,240,173,0.16); }
|
|
63
65
|
.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; }
|
|
64
66
|
.intro { position: absolute; left: 32px; top: 32px; z-index: 20; }
|
|
65
67
|
.intro strong { display: block; color: #779286; font-size: 12px; letter-spacing: 0.22em; text-transform: uppercase; }
|
|
@@ -68,6 +70,8 @@ function pageCss() {
|
|
|
68
70
|
.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); }
|
|
69
71
|
.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); }
|
|
70
72
|
.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
|
+
button.service-node { cursor: pointer; color: inherit; font: inherit; text-align: left; }
|
|
74
|
+
.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); }
|
|
71
75
|
.logo { width: 48px; height: 48px; flex: 0 0 auto; border-radius: 8px; display: grid; place-items: center; overflow: hidden; color: #fff; font-weight: 700; }
|
|
72
76
|
.logo img { width: 28px; height: 28px; object-fit: contain; filter: invert(1); }
|
|
73
77
|
.service-copy { min-width: 0; }
|
|
@@ -80,10 +84,46 @@ function pageCss() {
|
|
|
80
84
|
.legend i { display: inline-block; width: 8px; height: 8px; margin-right: 8px; border-radius: 999px; }
|
|
81
85
|
.scan-foot { position: absolute; right: 32px; bottom: 32px; z-index: 20; color: #53665d; text-align: right; font-size: 12px; line-height: 1.6; }
|
|
82
86
|
.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
|
+
.drawer-scrim { position: absolute; inset: 0; z-index: 24; background: rgba(0,0,0,0.58); pointer-events: none; }
|
|
88
|
+
.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); }
|
|
89
|
+
.details-head { display: flex; align-items: center; gap: 16px; padding: 26px 26px 22px; border-bottom: 1px solid rgba(255,255,255,0.08); }
|
|
90
|
+
.details-title { min-width: 0; flex: 1; }
|
|
91
|
+
.details-title span { display: block; color: #83978d; font-size: 13px; }
|
|
92
|
+
.details-title strong { display: block; margin-top: 6px; color: #f4fbf6; font-size: 28px; line-height: 1.1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
93
|
+
.close-button, .link-button, .delete-button, .save-button { border: 0; cursor: pointer; font: inherit; }
|
|
94
|
+
.close-button { width: 36px; height: 36px; border-radius: 999px; background: transparent; color: #91a59b; font-size: 26px; }
|
|
95
|
+
.details-body { height: calc(100% - 100px); overflow: auto; padding: 26px; }
|
|
96
|
+
.details-section { padding: 0 0 28px; margin: 0 0 28px; border-bottom: 1px solid rgba(255,255,255,0.08); }
|
|
97
|
+
.details-section:last-child { border-bottom: 0; margin-bottom: 0; }
|
|
98
|
+
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; color: #91a59b; font-size: 13px; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; }
|
|
99
|
+
.field-label { display: block; margin: 16px 0 8px; color: #83978d; font-size: 13px; }
|
|
100
|
+
.details-input, .details-textarea { width: 100%; border: 1px solid #2a3c33; border-radius: 10px; background: rgba(255,255,255,0.04); color: #e9f5ee; font: inherit; padding: 13px 14px; outline: none; }
|
|
101
|
+
.details-textarea { min-height: 96px; resize: vertical; line-height: 1.45; }
|
|
102
|
+
.details-input:focus, .details-textarea:focus { border-color: #65f0ad; box-shadow: 0 0 0 2px rgba(101,240,173,0.16); }
|
|
103
|
+
.links-list { display: grid; gap: 12px; }
|
|
104
|
+
.link-card { display: grid; grid-template-columns: 1fr auto; gap: 8px 10px; align-items: center; border: 1px solid #26382f; border-radius: 10px; background: rgba(255,255,255,0.035); padding: 14px; }
|
|
105
|
+
.link-card strong { color: #eef7f2; font-size: 14px; }
|
|
106
|
+
.link-card span { grid-column: 1 / -1; color: #74867d; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
107
|
+
.link-button { color: #8dc9ae; background: transparent; padding: 4px; }
|
|
108
|
+
.details-actions { position: absolute; left: 0; right: 0; bottom: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 26px; border-top: 1px solid rgba(255,255,255,0.08); background: rgba(8,17,12,0.98); }
|
|
109
|
+
.delete-button { color: #ff6b57; background: transparent; font-weight: 800; }
|
|
110
|
+
.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
|
+
.helper-text { margin: 10px 0 0; color: #687c72; font-size: 12px; line-height: 1.45; }
|
|
112
|
+
.three-scene { position: absolute; inset: 0; perspective: 920px; overflow: hidden; cursor: grab; touch-action: none; }
|
|
113
|
+
.three-scene:active { cursor: grabbing; }
|
|
114
|
+
.three-space { position: absolute; left: 50%; top: 50%; width: 620px; height: 620px; transform-style: preserve-3d; transform: translate(-50%, -50%) rotateX(var(--rx, 58deg)) rotateZ(var(--rz, -28deg)); transition: transform 140ms ease; pointer-events: none; }
|
|
115
|
+
.three-link { position: absolute; left: 50%; top: 50%; width: var(--length); height: 1px; transform-origin: 0 0; transform: translate3d(0, 0, 0) rotateZ(var(--angle)) translateY(var(--z)); background: linear-gradient(90deg, rgba(101,240,173,0.42), rgba(101,240,173,0.05)); }
|
|
116
|
+
.three-node { position: absolute; left: 50%; top: 50%; width: 82px; height: 82px; transform: translate3d(var(--x), var(--y), var(--z)) translate(-50%, -50%) rotateZ(28deg) rotateX(-58deg); border: 0; border-radius: 999px; background: transparent; color: #eaf8f1; cursor: pointer; font: inherit; text-align: center; pointer-events: auto; }
|
|
117
|
+
.three-orb { display: grid; place-items: center; width: 38px; height: 38px; margin: 0 auto 7px; border-radius: 999px; background: var(--color); color: #fff; box-shadow: 0 0 28px color-mix(in srgb, var(--color), transparent 44%); font-size: 12px; font-weight: 900; }
|
|
118
|
+
.three-node span { display: block; font-size: 10px; line-height: 1.15; text-shadow: 0 2px 8px #000; }
|
|
119
|
+
.three-node.is-selected .three-orb { outline: 2px solid #65f0ad; outline-offset: 4px; }
|
|
120
|
+
.three-core { position: absolute; left: 50%; top: 50%; display: grid; place-items: center; width: 72px; height: 72px; transform: translate(-50%, -50%); border-radius: 999px; background: #f7faf7; color: #101611; font-size: 30px; box-shadow: 0 0 0 10px rgba(101,240,173,0.14), 0 0 46px rgba(101,240,173,0.22); }
|
|
121
|
+
.three-hint { position: absolute; left: 50%; bottom: 30px; z-index: 21; transform: translateX(-50%); border: 1px solid rgba(255,255,255,0.1); border-radius: 999px; background: rgba(11,20,15,0.9); color: #789086; padding: 10px 18px; font-size: 12px; }
|
|
83
122
|
@media (max-width: 920px) {
|
|
84
123
|
.map { min-height: 1120px; }
|
|
85
124
|
.service-node { width: 220px; }
|
|
86
125
|
.top-actions { display: none; }
|
|
126
|
+
.details-panel { position: fixed; inset: auto 12px 12px; top: 110px; width: auto; }
|
|
87
127
|
}
|
|
88
128
|
`;
|
|
89
129
|
}
|
|
@@ -143,7 +183,10 @@ function htmlBody(graph) {
|
|
|
143
183
|
</div>
|
|
144
184
|
<div class="top-actions">
|
|
145
185
|
<span>${services.length} services</span>
|
|
146
|
-
<span class="mode-
|
|
186
|
+
<span class="mode-toggle" aria-label="View mode">
|
|
187
|
+
<span class="mode-button is-active">2D</span>
|
|
188
|
+
<span class="mode-button">3D</span>
|
|
189
|
+
</span>
|
|
147
190
|
</div>
|
|
148
191
|
</header>
|
|
149
192
|
<section class="map">
|
|
@@ -185,6 +228,320 @@ ${htmlBody(graph)}
|
|
|
185
228
|
`;
|
|
186
229
|
}
|
|
187
230
|
|
|
231
|
+
function interactiveTsxPage(graph) {
|
|
232
|
+
return `'use client';
|
|
233
|
+
|
|
234
|
+
import { useMemo, useState, type CSSProperties } from 'react';
|
|
235
|
+
|
|
236
|
+
const css = ${JSON.stringify(pageCss())};
|
|
237
|
+
type Evidence = { type?: string; value?: string; confidence?: string };
|
|
238
|
+
type RawService = { id: string; name: string; key?: string; category?: string; confidence?: string; evidence?: Evidence[]; iconUrl?: string | null };
|
|
239
|
+
type ServiceNode = RawService & { color: string; x: number; y: number };
|
|
240
|
+
type DraftDetails = { subtitle: string; use: string; account: string; passwordLocation: string };
|
|
241
|
+
type ServiceLink = { label: string; url: string };
|
|
242
|
+
|
|
243
|
+
const graph = ${JSON.stringify(graph)} as { generatedAt: string; project?: { name?: string }; services?: RawService[] };
|
|
244
|
+
const palette = ${JSON.stringify(palette)} as string[];
|
|
245
|
+
const layout = ${JSON.stringify(layout)} as Array<{ x: number; y: number }>;
|
|
246
|
+
|
|
247
|
+
function categoryLabel(value?: string | null) {
|
|
248
|
+
return String(value ?? 'service').replace(/-/g, ' ');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function initials(name: string) {
|
|
252
|
+
return name.split(/\\s+/).filter(Boolean).slice(0, 2).map((part) => part[0]).join('').toUpperCase();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function edgePath(node: ServiceNode) {
|
|
256
|
+
const isLeft = node.x < 50;
|
|
257
|
+
const fromX = isLeft ? node.x + 8 : node.x - 8;
|
|
258
|
+
const endX = isLeft ? 47 : 53;
|
|
259
|
+
const midX = isLeft ? Math.max(fromX + 7, 42) : Math.min(fromX - 7, 58);
|
|
260
|
+
return \`M \${fromX} \${node.y} H \${midX} V 50 H \${endX}\`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function threePosition(index: number, count: number) {
|
|
264
|
+
const angle = (index / Math.max(count, 1)) * Math.PI * 2 - Math.PI / 2;
|
|
265
|
+
const radius = index % 3 === 0 ? 270 : index % 3 === 1 ? 215 : 320;
|
|
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
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function threeNodeStyle(index: number, count: number, color: string): CSSProperties {
|
|
277
|
+
const position = threePosition(index, count);
|
|
278
|
+
return {
|
|
279
|
+
'--x': \`\${position.x}px\`,
|
|
280
|
+
'--y': \`\${position.y}px\`,
|
|
281
|
+
'--z': \`\${position.z}px\`,
|
|
282
|
+
'--color': color,
|
|
283
|
+
} as CSSProperties;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function threeLinkStyle(index: number, count: number): CSSProperties {
|
|
287
|
+
const position = threePosition(index, count);
|
|
288
|
+
return {
|
|
289
|
+
'--angle': \`\${position.angle}deg\`,
|
|
290
|
+
'--length': \`\${position.length}px\`,
|
|
291
|
+
'--z': \`\${position.z}px\`,
|
|
292
|
+
} as CSSProperties;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function primaryEvidence(service: RawService) {
|
|
296
|
+
const evidence = service.evidence?.[0];
|
|
297
|
+
return evidence ? \`\${evidence.type}: \${evidence.value}\` : 'No evidence captured yet.';
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function serviceLinks(service: RawService): ServiceLink[] {
|
|
301
|
+
const lower = service.name.toLowerCase();
|
|
302
|
+
const links: ServiceLink[] = [];
|
|
303
|
+
if (service.iconUrl) links.push({ label: \`\${service.name} docs\`, url: service.iconUrl.replace('/icons/', '/') });
|
|
304
|
+
if (lower.includes('firebase')) links.push({ label: 'Firebase console', url: 'https://console.firebase.google.com/' });
|
|
305
|
+
if (lower.includes('google')) links.push({ label: 'Google Cloud console', url: 'https://console.cloud.google.com/' });
|
|
306
|
+
if (lower.includes('cloudflare')) links.push({ label: 'Cloudflare dashboard', url: 'https://dash.cloudflare.com/' });
|
|
307
|
+
if (lower.includes('github')) links.push({ label: 'GitHub', url: 'https://github.com/' });
|
|
308
|
+
if (lower.includes('stripe')) links.push({ label: 'Stripe dashboard', url: 'https://dashboard.stripe.com/' });
|
|
309
|
+
if (lower.includes('vercel')) links.push({ label: 'Vercel dashboard', url: 'https://vercel.com/dashboard' });
|
|
310
|
+
if (lower.includes('microsoft')) links.push({ label: 'Microsoft Azure portal', url: 'https://portal.azure.com/' });
|
|
311
|
+
if (links.length === 0) links.push({ label: \`\${service.name} website\`, url: \`https://www.google.com/search?q=\${encodeURIComponent(service.name)}\` });
|
|
312
|
+
return links.slice(0, 4);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function ServiceLogo({ service }: { service: RawService & { color: string } }) {
|
|
316
|
+
return (
|
|
317
|
+
<div className="logo" style={{ background: service.color }}>
|
|
318
|
+
{service.iconUrl ? (
|
|
319
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
320
|
+
<img src={service.iconUrl} alt="" />
|
|
321
|
+
) : (
|
|
322
|
+
<span>{initials(service.name)}</span>
|
|
323
|
+
)}
|
|
324
|
+
</div>
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function DetailsPanel({
|
|
329
|
+
service,
|
|
330
|
+
details,
|
|
331
|
+
onChange,
|
|
332
|
+
onClose,
|
|
333
|
+
}: {
|
|
334
|
+
service: ServiceNode;
|
|
335
|
+
details: DraftDetails;
|
|
336
|
+
onChange: (details: DraftDetails) => void;
|
|
337
|
+
onClose: () => void;
|
|
338
|
+
}) {
|
|
339
|
+
const links = serviceLinks(service);
|
|
340
|
+
return (
|
|
341
|
+
<>
|
|
342
|
+
<div className="drawer-scrim" />
|
|
343
|
+
<aside className="details-panel" aria-label={\`\${service.name} details\`}>
|
|
344
|
+
<div className="details-head">
|
|
345
|
+
<ServiceLogo service={service} />
|
|
346
|
+
<div className="details-title">
|
|
347
|
+
<span>{categoryLabel(service.category)}</span>
|
|
348
|
+
<strong>{service.name}</strong>
|
|
349
|
+
</div>
|
|
350
|
+
<button type="button" className="close-button" onClick={onClose} aria-label="Close details">×</button>
|
|
351
|
+
</div>
|
|
352
|
+
|
|
353
|
+
<div className="details-body">
|
|
354
|
+
<section className="details-section">
|
|
355
|
+
<div className="section-heading">Service details</div>
|
|
356
|
+
<label className="field-label" htmlFor="service-subtitle">Service subtitle</label>
|
|
357
|
+
<input
|
|
358
|
+
id="service-subtitle"
|
|
359
|
+
className="details-input"
|
|
360
|
+
value={details.subtitle}
|
|
361
|
+
onChange={(event) => onChange({ ...details, subtitle: event.target.value })}
|
|
362
|
+
/>
|
|
363
|
+
|
|
364
|
+
<label className="field-label" htmlFor="service-use">How it is used</label>
|
|
365
|
+
<textarea
|
|
366
|
+
id="service-use"
|
|
367
|
+
className="details-textarea"
|
|
368
|
+
value={details.use}
|
|
369
|
+
onChange={(event) => onChange({ ...details, use: event.target.value })}
|
|
370
|
+
/>
|
|
371
|
+
</section>
|
|
372
|
+
|
|
373
|
+
<section className="details-section">
|
|
374
|
+
<div className="section-heading">
|
|
375
|
+
<span>Relevant links</span>
|
|
376
|
+
<span>+ Add</span>
|
|
377
|
+
</div>
|
|
378
|
+
<div className="links-list">
|
|
379
|
+
{links.map((link) => (
|
|
380
|
+
<div className="link-card" key={link.label}>
|
|
381
|
+
<strong>{link.label}</strong>
|
|
382
|
+
<a className="link-button" href={link.url} target="_blank" rel="noreferrer">Open</a>
|
|
383
|
+
<span>{link.url}</span>
|
|
384
|
+
</div>
|
|
385
|
+
))}
|
|
386
|
+
</div>
|
|
387
|
+
</section>
|
|
388
|
+
|
|
389
|
+
<section className="details-section">
|
|
390
|
+
<div className="section-heading">Account</div>
|
|
391
|
+
<label className="field-label" htmlFor="account-owner">Account owner or email</label>
|
|
392
|
+
<input
|
|
393
|
+
id="account-owner"
|
|
394
|
+
className="details-input"
|
|
395
|
+
value={details.account}
|
|
396
|
+
onChange={(event) => onChange({ ...details, account: event.target.value })}
|
|
397
|
+
placeholder="owner@example.com"
|
|
398
|
+
/>
|
|
399
|
+
|
|
400
|
+
<label className="field-label" htmlFor="password-location">Password / vault item</label>
|
|
401
|
+
<input
|
|
402
|
+
id="password-location"
|
|
403
|
+
className="details-input"
|
|
404
|
+
value={details.passwordLocation}
|
|
405
|
+
onChange={(event) => onChange({ ...details, passwordLocation: event.target.value })}
|
|
406
|
+
placeholder="1Password item, vault record, or SSO note"
|
|
407
|
+
/>
|
|
408
|
+
<p className="helper-text">Keep actual passwords in your password manager. Store only the vault item or access note here.</p>
|
|
409
|
+
</section>
|
|
410
|
+
</div>
|
|
411
|
+
|
|
412
|
+
<div className="details-actions">
|
|
413
|
+
<button type="button" className="delete-button">Delete</button>
|
|
414
|
+
<button type="button" className="save-button">Save changes</button>
|
|
415
|
+
</div>
|
|
416
|
+
</aside>
|
|
417
|
+
</>
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export default function InternalServiceMapPage() {
|
|
422
|
+
const nodes = useMemo<ServiceNode[]>(() => (graph.services ?? []).slice(0, layout.length).map((service, index) => ({
|
|
423
|
+
...service,
|
|
424
|
+
...layout[index],
|
|
425
|
+
color: palette[index % palette.length],
|
|
426
|
+
})), []);
|
|
427
|
+
const [mode, setMode] = useState<'2d' | '3d'>('2d');
|
|
428
|
+
const [rotation, setRotation] = useState({ x: 58, z: -28 });
|
|
429
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
430
|
+
const [drafts, setDrafts] = useState<Record<string, DraftDetails>>(() => Object.fromEntries(nodes.map((service) => [
|
|
431
|
+
service.id,
|
|
432
|
+
{
|
|
433
|
+
subtitle: categoryLabel(service.category),
|
|
434
|
+
use: primaryEvidence(service),
|
|
435
|
+
account: '',
|
|
436
|
+
passwordLocation: '',
|
|
437
|
+
},
|
|
438
|
+
])));
|
|
439
|
+
const selected = nodes.find((service) => service.id === selectedId) ?? null;
|
|
440
|
+
|
|
441
|
+
return (
|
|
442
|
+
<main className="servicemap-shell">
|
|
443
|
+
<style dangerouslySetInnerHTML={{ __html: css }} />
|
|
444
|
+
<header className="servicemap-topbar">
|
|
445
|
+
<div className="brand">
|
|
446
|
+
<div className="brand-mark">⌘</div>
|
|
447
|
+
<div>
|
|
448
|
+
<h1>servicemap</h1>
|
|
449
|
+
<p>{graph.project?.name ?? 'Project'} command center</p>
|
|
450
|
+
</div>
|
|
451
|
+
</div>
|
|
452
|
+
<div className="top-actions">
|
|
453
|
+
<span>{nodes.length} services</span>
|
|
454
|
+
<div className="mode-toggle" aria-label="View mode">
|
|
455
|
+
<button type="button" className={\`mode-button \${mode === '2d' ? 'is-active' : ''}\`} onClick={() => setMode('2d')}>2D</button>
|
|
456
|
+
<button type="button" className={\`mode-button \${mode === '3d' ? 'is-active' : ''}\`} onClick={() => setMode('3d')}>3D</button>
|
|
457
|
+
</div>
|
|
458
|
+
</div>
|
|
459
|
+
</header>
|
|
460
|
+
|
|
461
|
+
<section className="map">
|
|
462
|
+
<div className="intro">
|
|
463
|
+
<strong>{graph.project?.name ?? 'Project'} infrastructure</strong>
|
|
464
|
+
<span>{selected ? \`Tracing \${selected.name}\` : mode === '3d' ? 'Rotate and explore your service relationships' : 'Select a service to trace its dependencies'}</span>
|
|
465
|
+
</div>
|
|
466
|
+
{mode === '2d' ? (
|
|
467
|
+
<>
|
|
468
|
+
<svg className="edges" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
|
469
|
+
{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
|
+
<button
|
|
505
|
+
type="button"
|
|
506
|
+
key={service.id}
|
|
507
|
+
className={\`three-node \${selectedId === service.id ? 'is-selected' : ''}\`}
|
|
508
|
+
style={threeNodeStyle(index, nodes.length, service.color)}
|
|
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
|
+
}}
|
|
516
|
+
onClick={() => setSelectedId(service.id)}
|
|
517
|
+
>
|
|
518
|
+
<span className="three-orb">{initials(service.name)}</span>
|
|
519
|
+
<span>{service.name}</span>
|
|
520
|
+
</button>
|
|
521
|
+
))}
|
|
522
|
+
</div>
|
|
523
|
+
<div className="three-hint">Drag to rotate / Scroll to zoom / Click a service</div>
|
|
524
|
+
</div>
|
|
525
|
+
)}
|
|
526
|
+
<footer className="scan-foot">
|
|
527
|
+
<div>Generated {new Date(graph.generatedAt).toLocaleString()}</div>
|
|
528
|
+
<div>{nodes.length} services detected</div>
|
|
529
|
+
</footer>
|
|
530
|
+
{selected && drafts[selected.id] ? (
|
|
531
|
+
<DetailsPanel
|
|
532
|
+
service={selected}
|
|
533
|
+
details={drafts[selected.id]}
|
|
534
|
+
onClose={() => setSelectedId(null)}
|
|
535
|
+
onChange={(nextDetails) => setDrafts((current) => ({ ...current, [selected.id]: nextDetails }))}
|
|
536
|
+
/>
|
|
537
|
+
) : null}
|
|
538
|
+
</section>
|
|
539
|
+
</main>
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
`;
|
|
543
|
+
}
|
|
544
|
+
|
|
188
545
|
function tsxPage(graph) {
|
|
189
546
|
const html = htmlBody(graph);
|
|
190
547
|
const allowedEmails = graph.access?.allowedEmails ?? [];
|
|
@@ -217,18 +574,7 @@ export default async function InternalServiceMapPage() {
|
|
|
217
574
|
`;
|
|
218
575
|
}
|
|
219
576
|
|
|
220
|
-
return
|
|
221
|
-
const html = ${JSON.stringify(html)};
|
|
222
|
-
|
|
223
|
-
export default function InternalServiceMapPage() {
|
|
224
|
-
return (
|
|
225
|
-
<>
|
|
226
|
-
<style dangerouslySetInnerHTML={{ __html: css }} />
|
|
227
|
-
<div dangerouslySetInnerHTML={{ __html: html }} />
|
|
228
|
-
</>
|
|
229
|
-
);
|
|
230
|
-
}
|
|
231
|
-
`;
|
|
577
|
+
return interactiveTsxPage(graph);
|
|
232
578
|
}
|
|
233
579
|
|
|
234
580
|
function astroPage(graph) {
|