servicemap 0.1.34 → 0.1.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/servicemap/scripts/generate-page.mjs +235 -42
package/package.json
CHANGED
|
@@ -69,23 +69,29 @@ function pageCss() {
|
|
|
69
69
|
.brand h1 { margin: 0; font-size: 20px; line-height: 1; letter-spacing: 0; }
|
|
70
70
|
.brand p, .top-meta { margin: 6px 0 0; color: #8aa097; font-size: 14px; }
|
|
71
71
|
.top-actions { display: flex; align-items: center; gap: 12px; color: #8da59a; font-size: 14px; white-space: nowrap; }
|
|
72
|
+
.add-service-button { min-height: 40px; border: 0; border-radius: 10px; background: #287a5a; color: #effbf5; padding: 0 14px; cursor: pointer; font: inherit; font-weight: 800; box-shadow: 0 10px 28px rgba(54,180,126,0.16); }
|
|
72
73
|
.mode-toggle { display: inline-flex; gap: 4px; padding: 4px; border-radius: 10px; background: #0b130f; }
|
|
73
74
|
.mode-button { min-height: 36px; border: 0; border-radius: 8px; background: transparent; color: #789086; padding: 0 12px; cursor: pointer; font: inherit; font-weight: 800; }
|
|
74
75
|
.mode-button.is-active { background: #276f53; color: #c9f9df; box-shadow: 0 0 18px rgba(101,240,173,0.16); }
|
|
75
76
|
.map { position: relative; height: calc(100vh - 88px); min-height: 760px; background: radial-gradient(circle at 50% 52%, rgba(41, 96, 69, 0.22), transparent 24%), radial-gradient(circle at 1px 1px, rgba(88, 160, 120, 0.2) 1px, transparent 1px), #020806; background-size: auto, 28px 28px, auto; }
|
|
76
|
-
.map-
|
|
77
|
+
.map.is-panning { cursor: grabbing; user-select: none; }
|
|
78
|
+
.map-plane { position: absolute; inset: 0; z-index: 20; transform: translate3d(var(--pan-x, 0px), var(--pan-y, 0px), 0) scale(var(--zoom, 1)); transform-origin: 50% 50%; transition: transform 160ms ease; }
|
|
79
|
+
.map.is-panning .map-plane { transition: none; }
|
|
77
80
|
.intro { position: absolute; left: 32px; top: 32px; z-index: 20; }
|
|
78
81
|
.intro strong { display: block; color: #779286; font-size: 12px; letter-spacing: 0.22em; text-transform: uppercase; }
|
|
79
82
|
.intro span { display: block; margin-top: 8px; color: #53665d; font-size: 14px; }
|
|
80
83
|
.edges { position: absolute; inset: 0; z-index: 26; width: 100%; height: 100%; pointer-events: none; }
|
|
81
84
|
.edge-line { transition: opacity 180ms ease, stroke 180ms ease; }
|
|
82
|
-
.edge-line.is-active { stroke: #65f0ad !important; stroke-width:
|
|
83
|
-
.edge-line.is-muted { opacity: 0.
|
|
85
|
+
.edge-line.is-active { stroke: #65f0ad !important; stroke-width: 3px; stroke-linecap: round; stroke-dasharray: 10 8; animation: servicemapDash .74s linear infinite; filter: drop-shadow(0 0 9px rgba(101,240,173,0.9)) drop-shadow(0 0 18px rgba(101,240,173,0.45)); opacity: 1; }
|
|
86
|
+
.edge-line.is-muted { opacity: 0.12; }
|
|
84
87
|
.hub { position: absolute; left: 50%; top: 50%; z-index: 20; width: 132px; height: 132px; transform: translate(-50%, -50%); border-radius: 24px; border: 1px solid #315c47; background: #09120d; display: grid; place-items: center; box-shadow: 0 0 0 9px rgba(79,183,128,0.12), 0 0 42px rgba(100,255,174,0.16); }
|
|
85
88
|
.hub-inner { width: 92px; height: 92px; border-radius: 18px; background: #fbfbf7; color: #111612; display: grid; place-items: center; font-size: 40px; box-shadow: inset 0 0 0 1px rgba(0,0,0,0.08); }
|
|
86
89
|
.service-node { position: absolute; z-index: 10; width: 250px; height: 74px; transform: translate(-50%, -50%); display: flex; align-items: center; gap: 16px; padding: 0 16px; border-radius: 8px; border: 1px solid #274036; background: rgba(16,24,19,0.95); box-shadow: 0 0 0 1px rgba(140,255,190,0.05), 0 18px 44px rgba(0,0,0,0.38); }
|
|
87
90
|
button.service-node { cursor: pointer; color: inherit; font: inherit; text-align: left; }
|
|
88
91
|
.service-node:hover, .service-node.is-selected { border-color: #66efae; box-shadow: 0 0 0 2px rgba(101,240,173,0.42), 0 18px 44px rgba(0,0,0,0.38); }
|
|
92
|
+
.map.has-selection .service-node { opacity: 0.16; }
|
|
93
|
+
.map.has-selection .service-node.is-related { opacity: 0.48; }
|
|
94
|
+
.map.has-selection .service-node.is-selected { opacity: 1; }
|
|
89
95
|
.service-node.is-selected { z-index: 27; }
|
|
90
96
|
.logo { width: 48px; height: 48px; flex: 0 0 auto; border-radius: 8px; display: grid; place-items: center; overflow: hidden; color: #fff; font-weight: 700; }
|
|
91
97
|
.logo img { width: 28px; height: 28px; object-fit: contain; filter: invert(1); }
|
|
@@ -94,7 +100,7 @@ function pageCss() {
|
|
|
94
100
|
.service-copy span { display: block; margin-top: 5px; color: #7f9188; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
95
101
|
.status-dot { margin-left: auto; width: 8px; height: 8px; flex: 0 0 auto; border-radius: 999px; box-shadow: 0 0 14px currentColor; }
|
|
96
102
|
.status-high { color: #65f0ad; background: #65f0ad; }
|
|
97
|
-
.status-medium, .status-low { color: #f1c85b; background: #f1c85b; }
|
|
103
|
+
.status-medium, .status-low, .status-manual { color: #f1c85b; background: #f1c85b; }
|
|
98
104
|
.legend { position: absolute; left: 50%; bottom: 32px; z-index: 20; transform: translateX(-50%); display: flex; align-items: center; gap: 20px; border: 1px solid rgba(255,255,255,0.1); border-radius: 999px; background: rgba(11,20,15,0.9); color: #789086; padding: 8px 20px; font-size: 12px; }
|
|
99
105
|
.legend i { display: inline-block; width: 8px; height: 8px; margin-right: 8px; border-radius: 999px; }
|
|
100
106
|
.zoom-controls { position: absolute; left: 28px; bottom: 26px; z-index: 29; display: grid; overflow: hidden; border: 1px solid rgba(255,255,255,0.12); border-radius: 10px; background: rgba(7,14,10,0.9); box-shadow: 0 12px 32px rgba(0,0,0,0.34); }
|
|
@@ -104,7 +110,7 @@ function pageCss() {
|
|
|
104
110
|
.fit-icon { display: inline-block; width: 15px; height: 15px; border: 2px solid currentColor; border-radius: 3px; }
|
|
105
111
|
.scan-foot { position: absolute; right: 32px; bottom: 32px; z-index: 20; color: #53665d; text-align: right; font-size: 12px; line-height: 1.6; }
|
|
106
112
|
.access-warning { position: absolute; left: 32px; right: 32px; bottom: 88px; z-index: 25; max-width: 720px; border: 1px solid rgba(241,200,91,0.42); border-radius: 8px; background: rgba(31,27,12,0.92); color: #f7daa0; padding: 12px 14px; font-size: 14px; line-height: 1.45; }
|
|
107
|
-
.drawer-scrim { position: absolute; inset: 0; z-index:
|
|
113
|
+
.drawer-scrim { position: absolute; inset: 0; z-index: 18; background: rgba(0,0,0,0.58); pointer-events: none; }
|
|
108
114
|
.details-panel { position: absolute; right: 22px; top: 64px; bottom: 26px; z-index: 30; width: min(492px, calc(100vw - 44px)); overflow: hidden; border: 1px solid #294034; border-radius: 24px; background: linear-gradient(180deg, rgba(21,31,25,0.98), rgba(8,17,12,0.98)); box-shadow: 0 24px 80px rgba(0,0,0,0.52); }
|
|
109
115
|
.details-head { display: flex; align-items: center; gap: 16px; padding: 26px 26px 22px; border-bottom: 1px solid rgba(255,255,255,0.08); }
|
|
110
116
|
.details-title { min-width: 0; flex: 1; }
|
|
@@ -129,6 +135,12 @@ function pageCss() {
|
|
|
129
135
|
.delete-button { color: #ff6b57; background: transparent; font-weight: 800; }
|
|
130
136
|
.save-button { min-height: 48px; border-radius: 10px; background: #287a5a; color: #effbf5; padding: 0 18px; font-weight: 800; box-shadow: 0 10px 30px rgba(54,180,126,0.18); }
|
|
131
137
|
.helper-text { margin: 10px 0 0; color: #687c72; font-size: 12px; line-height: 1.45; }
|
|
138
|
+
.modal-card { position: absolute; left: 50%; top: 50%; z-index: 31; width: min(440px, calc(100vw - 44px)); transform: translate(-50%, -50%); border: 1px solid #294034; border-radius: 20px; background: linear-gradient(180deg, rgba(21,31,25,0.99), rgba(8,17,12,0.99)); box-shadow: 0 24px 80px rgba(0,0,0,0.56); overflow: hidden; }
|
|
139
|
+
.modal-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 22px 24px; border-bottom: 1px solid rgba(255,255,255,0.08); }
|
|
140
|
+
.modal-head h2 { margin: 0; color: #f4fbf6; font-size: 20px; letter-spacing: 0; }
|
|
141
|
+
.modal-body { padding: 22px 24px 24px; }
|
|
142
|
+
.modal-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 22px; }
|
|
143
|
+
.secondary-button { min-height: 44px; border: 1px solid #2a3c33; border-radius: 10px; background: rgba(255,255,255,0.04); color: #c8d8d0; padding: 0 16px; cursor: pointer; font: inherit; font-weight: 800; }
|
|
132
144
|
.graph-3d { position: absolute; inset: 0; overflow: hidden; }
|
|
133
145
|
.graph-3d::before { content: ''; position: absolute; z-index: 1; inset: 0; pointer-events: none; background: radial-gradient(circle at 50% 50%, transparent 38%, rgba(2,5,4,.46) 100%); }
|
|
134
146
|
.graph-3d canvas { display: block; cursor: grab; }
|
|
@@ -263,16 +275,17 @@ function interactiveTsxPage(graph) {
|
|
|
263
275
|
return `'use client';
|
|
264
276
|
|
|
265
277
|
import dynamic from 'next/dynamic';
|
|
266
|
-
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
|
278
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent } from 'react';
|
|
267
279
|
import type { ForceGraphMethods, NodeObject } from 'react-force-graph-3d';
|
|
268
280
|
import * as THREE from 'three';
|
|
269
281
|
import SpriteText from 'three-spritetext';
|
|
270
282
|
|
|
271
283
|
const css = ${JSON.stringify(pageCss())};
|
|
272
284
|
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 };
|
|
285
|
+
type RawService = { id: string; name: string; key?: string; category?: string; confidence?: string; evidence?: Evidence[]; iconUrl?: string | null; parentId?: string | null };
|
|
274
286
|
type ServiceNode = RawService & { color: string; x: number; y: number };
|
|
275
287
|
type DraftDetails = { subtitle: string; use: string; account: string; passwordLocation: string };
|
|
288
|
+
type NewServiceDraft = { name: string; category: string; parentId: string };
|
|
276
289
|
type ServiceLink = { label: string; url: string };
|
|
277
290
|
type GraphNode3D = { id: string; name: string; category: string; color: string; iconUrl?: string | null; monogram: string; isProduct: boolean; x?: number; y?: number; z?: number };
|
|
278
291
|
type GraphLink3D = { id: string; source: string | GraphNode3D; target: string | GraphNode3D; label: string };
|
|
@@ -293,11 +306,16 @@ function initials(name: string) {
|
|
|
293
306
|
}
|
|
294
307
|
|
|
295
308
|
function edgePath(node: ServiceNode) {
|
|
309
|
+
return edgePathBetween({ x: 50, y: 50 }, node);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function edgePathBetween(parent: { x: number; y: number }, node: ServiceNode) {
|
|
296
313
|
const isLeft = node.x < 50;
|
|
297
|
-
const fromX =
|
|
298
|
-
const
|
|
299
|
-
const
|
|
300
|
-
|
|
314
|
+
const fromX = parent.x;
|
|
315
|
+
const fromY = parent.y;
|
|
316
|
+
const endX = isLeft ? node.x + 8 : node.x - 8;
|
|
317
|
+
const midX = (fromX + endX) / 2;
|
|
318
|
+
return \`M \${fromX} \${fromY} H \${midX} V \${node.y} H \${endX}\`;
|
|
301
319
|
}
|
|
302
320
|
|
|
303
321
|
function primaryEvidence(service: RawService) {
|
|
@@ -317,6 +335,25 @@ function graphLink(link: GraphLink3D) {
|
|
|
317
335
|
return link;
|
|
318
336
|
}
|
|
319
337
|
|
|
338
|
+
function slugify(value: string) {
|
|
339
|
+
return value.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'service';
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function servicePosition(index: number) {
|
|
343
|
+
const point = layout[index];
|
|
344
|
+
if (point) return point;
|
|
345
|
+
const angle = (index - layout.length) * 2.399963229728653;
|
|
346
|
+
const radius = 34 + ((index - layout.length) % 4) * 4;
|
|
347
|
+
return {
|
|
348
|
+
x: 50 + Math.cos(angle) * radius,
|
|
349
|
+
y: 50 + Math.sin(angle) * radius * 0.72,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function relationshipLabel(parentId?: string | null) {
|
|
354
|
+
return parentId ? 'depends on' : 'uses';
|
|
355
|
+
}
|
|
356
|
+
|
|
320
357
|
function iconObject(node: GraphNode3D, selected: boolean) {
|
|
321
358
|
const group = new THREE.Group();
|
|
322
359
|
const radius = node.isProduct ? 10 : 7;
|
|
@@ -367,7 +404,7 @@ function iconObject(node: GraphNode3D, selected: boolean) {
|
|
|
367
404
|
|
|
368
405
|
const label = new SpriteText(node.name);
|
|
369
406
|
label.color = selected ? '#b9f6da' : '#dfeae4';
|
|
370
|
-
label.textHeight = selected ?
|
|
407
|
+
label.textHeight = selected ? 4.1 : 3.2;
|
|
371
408
|
label.fontSize = 220;
|
|
372
409
|
label.fontWeight = selected ? '700' : '600';
|
|
373
410
|
label.backgroundColor = 'rgba(5, 8, 7, .72)';
|
|
@@ -511,6 +548,70 @@ function DetailsPanel({
|
|
|
511
548
|
);
|
|
512
549
|
}
|
|
513
550
|
|
|
551
|
+
function AddServiceModal({
|
|
552
|
+
draft,
|
|
553
|
+
nodes,
|
|
554
|
+
onChange,
|
|
555
|
+
onClose,
|
|
556
|
+
onSubmit,
|
|
557
|
+
}: {
|
|
558
|
+
draft: NewServiceDraft;
|
|
559
|
+
nodes: ServiceNode[];
|
|
560
|
+
onChange: (draft: NewServiceDraft) => void;
|
|
561
|
+
onClose: () => void;
|
|
562
|
+
onSubmit: () => void;
|
|
563
|
+
}) {
|
|
564
|
+
return (
|
|
565
|
+
<>
|
|
566
|
+
<div className="drawer-scrim" />
|
|
567
|
+
<section className="modal-card" aria-label="Add service">
|
|
568
|
+
<div className="modal-head">
|
|
569
|
+
<h2>Add service</h2>
|
|
570
|
+
<button type="button" className="close-button" onClick={onClose} aria-label="Close add service">×</button>
|
|
571
|
+
</div>
|
|
572
|
+
<div className="modal-body">
|
|
573
|
+
<label className="field-label" htmlFor="new-service-name">Service name</label>
|
|
574
|
+
<input
|
|
575
|
+
id="new-service-name"
|
|
576
|
+
className="details-input"
|
|
577
|
+
value={draft.name}
|
|
578
|
+
onChange={(event) => onChange({ ...draft, name: event.target.value })}
|
|
579
|
+
placeholder="Stripe, Vercel, Google Cloud"
|
|
580
|
+
autoFocus
|
|
581
|
+
/>
|
|
582
|
+
|
|
583
|
+
<label className="field-label" htmlFor="new-service-category">Use</label>
|
|
584
|
+
<input
|
|
585
|
+
id="new-service-category"
|
|
586
|
+
className="details-input"
|
|
587
|
+
value={draft.category}
|
|
588
|
+
onChange={(event) => onChange({ ...draft, category: event.target.value })}
|
|
589
|
+
placeholder="billing, hosting, analytics"
|
|
590
|
+
/>
|
|
591
|
+
|
|
592
|
+
<label className="field-label" htmlFor="new-service-parent">Parent node</label>
|
|
593
|
+
<select
|
|
594
|
+
id="new-service-parent"
|
|
595
|
+
className="details-input"
|
|
596
|
+
value={draft.parentId}
|
|
597
|
+
onChange={(event) => onChange({ ...draft, parentId: event.target.value })}
|
|
598
|
+
>
|
|
599
|
+
<option value="product">{graph.project?.name ?? 'Product'}</option>
|
|
600
|
+
{nodes.map((node) => (
|
|
601
|
+
<option key={node.id} value={node.id}>{node.name}</option>
|
|
602
|
+
))}
|
|
603
|
+
</select>
|
|
604
|
+
|
|
605
|
+
<div className="modal-actions">
|
|
606
|
+
<button type="button" className="secondary-button" onClick={onClose}>Cancel</button>
|
|
607
|
+
<button type="button" className="save-button" onClick={onSubmit}>Add service</button>
|
|
608
|
+
</div>
|
|
609
|
+
</div>
|
|
610
|
+
</section>
|
|
611
|
+
</>
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
|
|
514
615
|
function ServiceGraph3D({
|
|
515
616
|
nodes,
|
|
516
617
|
selectedId,
|
|
@@ -524,6 +625,7 @@ function ServiceGraph3D({
|
|
|
524
625
|
const graphRef = useRef<ForceGraphMethods<GraphNode3D, GraphLink3D> | undefined>(undefined);
|
|
525
626
|
const [size, setSize] = useState({ width: 900, height: 700 });
|
|
526
627
|
const hasFramed = useRef(false);
|
|
628
|
+
const hasConfiguredForces = useRef(false);
|
|
527
629
|
const graphData = useMemo(() => ({
|
|
528
630
|
nodes: [{
|
|
529
631
|
id: 'project',
|
|
@@ -543,10 +645,10 @@ function ServiceGraph3D({
|
|
|
543
645
|
isProduct: node.category === 'product',
|
|
544
646
|
}))],
|
|
545
647
|
links: nodes.map((node): GraphLink3D => ({
|
|
546
|
-
id:
|
|
547
|
-
source: 'project',
|
|
648
|
+
id: \`\${node.parentId ?? 'project'}-\${node.id}\`,
|
|
649
|
+
source: node.parentId ?? 'project',
|
|
548
650
|
target: node.id,
|
|
549
|
-
label: node.confidence === 'high' ?
|
|
651
|
+
label: node.confidence === 'high' ? relationshipLabel(node.parentId) : \`may \${relationshipLabel(node.parentId)}\`,
|
|
550
652
|
})),
|
|
551
653
|
}), [nodes]);
|
|
552
654
|
|
|
@@ -573,17 +675,24 @@ function ServiceGraph3D({
|
|
|
573
675
|
return () => observer.disconnect();
|
|
574
676
|
}, []);
|
|
575
677
|
|
|
576
|
-
|
|
678
|
+
const configureForces = useCallback(() => {
|
|
577
679
|
const graphInstance = graphRef.current;
|
|
578
|
-
if (!graphInstance) return;
|
|
680
|
+
if (!graphInstance || hasConfiguredForces.current) return;
|
|
579
681
|
const charge = graphInstance.d3Force('charge') as AdjustableForce | undefined;
|
|
580
682
|
const link = graphInstance.d3Force('link') as AdjustableForce | undefined;
|
|
581
683
|
graphInstance.renderer().setPixelRatio(Math.min(window.devicePixelRatio * 1.5, 3));
|
|
582
|
-
charge?.strength?.(-
|
|
583
|
-
link?.distance?.(
|
|
684
|
+
charge?.strength?.(-720);
|
|
685
|
+
link?.distance?.(210);
|
|
584
686
|
graphInstance.d3ReheatSimulation();
|
|
687
|
+
hasConfiguredForces.current = true;
|
|
688
|
+
}, []);
|
|
689
|
+
|
|
690
|
+
useEffect(() => {
|
|
585
691
|
hasFramed.current = false;
|
|
586
|
-
|
|
692
|
+
hasConfiguredForces.current = false;
|
|
693
|
+
const frame = window.requestAnimationFrame(configureForces);
|
|
694
|
+
return () => window.cancelAnimationFrame(frame);
|
|
695
|
+
}, [configureForces, graphData.nodes.length, graphData.links.length]);
|
|
587
696
|
|
|
588
697
|
const focusNode = useCallback((node: NodeObject) => {
|
|
589
698
|
const serviceNode = graphNode(node);
|
|
@@ -653,16 +762,17 @@ function ServiceGraph3D({
|
|
|
653
762
|
linkDirectionalParticleWidth={1.8}
|
|
654
763
|
linkDirectionalParticleSpeed={0.006}
|
|
655
764
|
linkDirectionalParticleColor={() => '#b9f6da'}
|
|
656
|
-
d3AlphaDecay={0.
|
|
657
|
-
d3VelocityDecay={0.
|
|
658
|
-
warmupTicks={
|
|
659
|
-
cooldownTicks={
|
|
765
|
+
d3AlphaDecay={0.026}
|
|
766
|
+
d3VelocityDecay={0.18}
|
|
767
|
+
warmupTicks={90}
|
|
768
|
+
cooldownTicks={260}
|
|
660
769
|
onNodeClick={focusNode}
|
|
661
770
|
onBackgroundClick={() => onSelect(null)}
|
|
771
|
+
onEngineTick={configureForces}
|
|
662
772
|
onEngineStop={() => {
|
|
663
773
|
if (hasFramed.current) return;
|
|
664
774
|
hasFramed.current = true;
|
|
665
|
-
graphRef.current?.zoomToFit(700,
|
|
775
|
+
graphRef.current?.zoomToFit(700, 130);
|
|
666
776
|
}}
|
|
667
777
|
/>
|
|
668
778
|
<div className="graph-3d__zoom" aria-label="3D zoom controls">
|
|
@@ -676,14 +786,19 @@ function ServiceGraph3D({
|
|
|
676
786
|
}
|
|
677
787
|
|
|
678
788
|
export default function InternalServiceMapPage() {
|
|
679
|
-
const
|
|
789
|
+
const [services, setServices] = useState<RawService[]>(() => graph.services ?? []);
|
|
790
|
+
const nodes = useMemo<ServiceNode[]>(() => services.map((service, index) => ({
|
|
680
791
|
...service,
|
|
681
|
-
...
|
|
792
|
+
...servicePosition(index),
|
|
682
793
|
color: palette[index % palette.length],
|
|
683
|
-
})), []);
|
|
794
|
+
})), [services]);
|
|
684
795
|
const [mode, setMode] = useState<'2d' | '3d'>('2d');
|
|
685
796
|
const [zoom, setZoom] = useState(1);
|
|
797
|
+
const [pan, setPan] = useState({ x: 0, y: 0 });
|
|
798
|
+
const [panStart, setPanStart] = useState<{ pointerId: number; x: number; y: number; panX: number; panY: number } | null>(null);
|
|
686
799
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
800
|
+
const [isAddingService, setIsAddingService] = useState(false);
|
|
801
|
+
const [newService, setNewService] = useState<NewServiceDraft>({ name: '', category: '', parentId: 'product' });
|
|
687
802
|
const [drafts, setDrafts] = useState<Record<string, DraftDetails>>(() => Object.fromEntries(nodes.map((service) => [
|
|
688
803
|
service.id,
|
|
689
804
|
{
|
|
@@ -694,9 +809,64 @@ export default function InternalServiceMapPage() {
|
|
|
694
809
|
},
|
|
695
810
|
])));
|
|
696
811
|
const selected = nodes.find((service) => service.id === selectedId) ?? null;
|
|
812
|
+
const nodeById = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]);
|
|
697
813
|
const zoomIn = () => setZoom((current) => Math.min(1.45, Number((current + 0.12).toFixed(2))));
|
|
698
814
|
const zoomOut = () => setZoom((current) => Math.max(0.7, Number((current - 0.12).toFixed(2))));
|
|
699
|
-
const resetZoom = () =>
|
|
815
|
+
const resetZoom = () => {
|
|
816
|
+
setZoom(1);
|
|
817
|
+
setPan({ x: 0, y: 0 });
|
|
818
|
+
};
|
|
819
|
+
const startPan = (event: PointerEvent<HTMLElement>) => {
|
|
820
|
+
if (mode !== '2d' || event.button !== 0 || (event.target as HTMLElement).closest('button, a, input, textarea, select')) return;
|
|
821
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
822
|
+
setPanStart({ pointerId: event.pointerId, x: event.clientX, y: event.clientY, panX: pan.x, panY: pan.y });
|
|
823
|
+
};
|
|
824
|
+
const movePan = (event: PointerEvent<HTMLElement>) => {
|
|
825
|
+
if (!panStart || panStart.pointerId !== event.pointerId) return;
|
|
826
|
+
setPan({
|
|
827
|
+
x: panStart.panX + event.clientX - panStart.x,
|
|
828
|
+
y: panStart.panY + event.clientY - panStart.y,
|
|
829
|
+
});
|
|
830
|
+
};
|
|
831
|
+
const stopPan = (event: PointerEvent<HTMLElement>) => {
|
|
832
|
+
if (panStart?.pointerId !== event.pointerId) return;
|
|
833
|
+
setPanStart(null);
|
|
834
|
+
};
|
|
835
|
+
const addService = () => {
|
|
836
|
+
const name = newService.name.trim();
|
|
837
|
+
if (!name) return;
|
|
838
|
+
const baseId = slugify(name);
|
|
839
|
+
const existingIds = new Set(services.map((service) => service.id));
|
|
840
|
+
let id = baseId;
|
|
841
|
+
let suffix = 2;
|
|
842
|
+
while (existingIds.has(id)) {
|
|
843
|
+
id = \`\${baseId}-\${suffix}\`;
|
|
844
|
+
suffix += 1;
|
|
845
|
+
}
|
|
846
|
+
const service: RawService = {
|
|
847
|
+
id,
|
|
848
|
+
key: id,
|
|
849
|
+
name,
|
|
850
|
+
category: newService.category.trim() || 'service',
|
|
851
|
+
confidence: 'manual',
|
|
852
|
+
evidence: [{ type: 'manual', value: 'Added in servicemap UI.', confidence: 'manual' }],
|
|
853
|
+
iconUrl: null,
|
|
854
|
+
parentId: newService.parentId === 'product' ? null : newService.parentId,
|
|
855
|
+
};
|
|
856
|
+
setServices((current) => [...current, service]);
|
|
857
|
+
setDrafts((current) => ({
|
|
858
|
+
...current,
|
|
859
|
+
[id]: {
|
|
860
|
+
subtitle: categoryLabel(service.category),
|
|
861
|
+
use: primaryEvidence(service),
|
|
862
|
+
account: '',
|
|
863
|
+
passwordLocation: '',
|
|
864
|
+
},
|
|
865
|
+
}));
|
|
866
|
+
setSelectedId(id);
|
|
867
|
+
setNewService({ name: '', category: '', parentId: 'product' });
|
|
868
|
+
setIsAddingService(false);
|
|
869
|
+
};
|
|
700
870
|
|
|
701
871
|
return (
|
|
702
872
|
<main className="servicemap-shell">
|
|
@@ -711,6 +881,7 @@ export default function InternalServiceMapPage() {
|
|
|
711
881
|
</div>
|
|
712
882
|
<div className="top-actions">
|
|
713
883
|
<span>{nodes.length} services</span>
|
|
884
|
+
<button type="button" className="add-service-button" onClick={() => setIsAddingService(true)}>+ Add service</button>
|
|
714
885
|
<div className="mode-toggle" aria-label="View mode">
|
|
715
886
|
<button type="button" className={\`mode-button \${mode === '2d' ? 'is-active' : ''}\`} onClick={() => setMode('2d')}>2D</button>
|
|
716
887
|
<button type="button" className={\`mode-button \${mode === '3d' ? 'is-active' : ''}\`} onClick={() => setMode('3d')}>3D</button>
|
|
@@ -718,35 +889,47 @@ export default function InternalServiceMapPage() {
|
|
|
718
889
|
</div>
|
|
719
890
|
</header>
|
|
720
891
|
|
|
721
|
-
<section
|
|
892
|
+
<section
|
|
893
|
+
className={\`map \${panStart ? 'is-panning' : ''} \${selectedId ? 'has-selection' : ''}\`}
|
|
894
|
+
onPointerDown={startPan}
|
|
895
|
+
onPointerMove={movePan}
|
|
896
|
+
onPointerUp={stopPan}
|
|
897
|
+
onPointerCancel={stopPan}
|
|
898
|
+
>
|
|
722
899
|
<div className="intro">
|
|
723
900
|
<strong>{graph.project?.name ?? 'Project'} infrastructure</strong>
|
|
724
901
|
<span>{selected ? \`Tracing \${selected.name}\` : mode === '3d' ? 'Rotate and explore your service relationships' : 'Select a service to trace its dependencies'}</span>
|
|
725
902
|
</div>
|
|
726
903
|
{mode === '2d' ? (
|
|
727
904
|
<>
|
|
728
|
-
<div className="map-plane" style={{ '--zoom': zoom } as CSSProperties}>
|
|
905
|
+
<div className="map-plane" style={{ '--zoom': zoom, '--pan-x': \`\${pan.x}px\`, '--pan-y': \`\${pan.y}px\` } as CSSProperties}>
|
|
729
906
|
<svg className="edges" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
|
730
|
-
{nodes.map((service) =>
|
|
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 (
|
|
731
911
|
<path
|
|
732
912
|
key={service.id}
|
|
733
|
-
className={\`edge-line \${
|
|
734
|
-
d={edgePath(service)}
|
|
913
|
+
className={\`edge-line \${isActive ? 'is-active' : selectedId ? 'is-muted' : ''}\`}
|
|
914
|
+
d={parent ? edgePathBetween(parent, service) : edgePath(service)}
|
|
735
915
|
fill="none"
|
|
736
|
-
stroke={
|
|
737
|
-
strokeWidth={
|
|
738
|
-
strokeDasharray={
|
|
739
|
-
strokeLinecap={
|
|
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}
|
|
740
920
|
vectorEffect="non-scaling-stroke"
|
|
741
921
|
/>
|
|
742
|
-
|
|
922
|
+
);
|
|
923
|
+
})}
|
|
743
924
|
</svg>
|
|
744
925
|
<div className="hub"><div className="hub-inner"><ProjectMark /></div></div>
|
|
745
|
-
{nodes.map((service) =>
|
|
926
|
+
{nodes.map((service) => {
|
|
927
|
+
const isRelated = selectedId === service.parentId || selected?.parentId === service.id;
|
|
928
|
+
return (
|
|
746
929
|
<button
|
|
747
930
|
type="button"
|
|
748
931
|
key={service.id}
|
|
749
|
-
className={\`service-node \${selectedId === service.id ? 'is-selected' : ''}\`}
|
|
932
|
+
className={\`service-node \${selectedId === service.id ? 'is-selected' : ''} \${isRelated ? 'is-related' : ''}\`}
|
|
750
933
|
style={{ left: \`\${service.x}%\`, top: \`\${service.y}%\` }}
|
|
751
934
|
onClick={() => setSelectedId(service.id)}
|
|
752
935
|
>
|
|
@@ -757,7 +940,8 @@ export default function InternalServiceMapPage() {
|
|
|
757
940
|
</div>
|
|
758
941
|
<i className={\`status-dot status-\${service.confidence}\`} />
|
|
759
942
|
</button>
|
|
760
|
-
|
|
943
|
+
);
|
|
944
|
+
})}
|
|
761
945
|
</div>
|
|
762
946
|
<div className="legend">
|
|
763
947
|
<span><i style={{ background: '#4a7cff' }} />Cloud</span>
|
|
@@ -785,6 +969,15 @@ export default function InternalServiceMapPage() {
|
|
|
785
969
|
onChange={(nextDetails) => setDrafts((current) => ({ ...current, [selected.id]: nextDetails }))}
|
|
786
970
|
/>
|
|
787
971
|
) : null}
|
|
972
|
+
{isAddingService ? (
|
|
973
|
+
<AddServiceModal
|
|
974
|
+
draft={newService}
|
|
975
|
+
nodes={nodes}
|
|
976
|
+
onChange={setNewService}
|
|
977
|
+
onClose={() => setIsAddingService(false)}
|
|
978
|
+
onSubmit={addService}
|
|
979
|
+
/>
|
|
980
|
+
) : null}
|
|
788
981
|
</section>
|
|
789
982
|
</main>
|
|
790
983
|
);
|