servicemap 0.1.26 → 0.1.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
|
-
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
-
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { dirname, extname, isAbsolute, join, resolve } from 'node:path';
|
|
5
5
|
|
|
6
6
|
const defaultInput = '.servicemap/internal-servicemap.json';
|
|
7
7
|
|
|
@@ -56,18 +56,27 @@ function pageCss() {
|
|
|
56
56
|
.servicemap-topbar { height: 88px; display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 0 28px; border-bottom: 1px solid rgba(255,255,255,0.08); background: #222823; }
|
|
57
57
|
.brand { display: flex; align-items: center; gap: 16px; min-width: 0; }
|
|
58
58
|
.brand-mark { width: 48px; height: 48px; border-radius: 10px; border: 1px solid #2a7958; background: #164d39; color: #7cf1b5; display: grid; place-items: center; box-shadow: 0 0 26px rgba(82,255,166,0.16); font-size: 22px; }
|
|
59
|
+
.brand-mark img, .hub-inner img, .three-core img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }
|
|
59
60
|
.brand h1 { margin: 0; font-size: 20px; line-height: 1; letter-spacing: 0; }
|
|
60
61
|
.brand p, .top-meta { margin: 6px 0 0; color: #8aa097; font-size: 14px; }
|
|
61
62
|
.top-actions { display: flex; align-items: center; gap: 12px; color: #8da59a; font-size: 14px; white-space: nowrap; }
|
|
62
|
-
.mode-
|
|
63
|
+
.mode-toggle { display: inline-flex; gap: 4px; padding: 4px; border-radius: 10px; background: #0b130f; }
|
|
64
|
+
.mode-button { min-height: 36px; border: 0; border-radius: 8px; background: transparent; color: #789086; padding: 0 12px; cursor: pointer; font: inherit; font-weight: 800; }
|
|
65
|
+
.mode-button.is-active { background: #276f53; color: #c9f9df; box-shadow: 0 0 18px rgba(101,240,173,0.16); }
|
|
63
66
|
.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
67
|
.intro { position: absolute; left: 32px; top: 32px; z-index: 20; }
|
|
65
68
|
.intro strong { display: block; color: #779286; font-size: 12px; letter-spacing: 0.22em; text-transform: uppercase; }
|
|
66
69
|
.intro span { display: block; margin-top: 8px; color: #53665d; font-size: 14px; }
|
|
67
|
-
.edges { position: absolute; inset: 0; width: 100%; height: 100%; }
|
|
70
|
+
.edges { position: absolute; inset: 0; z-index: 26; width: 100%; height: 100%; pointer-events: none; }
|
|
71
|
+
.edge-line { transition: opacity 180ms ease, stroke 180ms ease; }
|
|
72
|
+
.edge-line.is-active { stroke: #65f0ad !important; stroke-width: 0.28; stroke-linecap: round; stroke-dasharray: 0.72 0.58; animation: servicemapDash 1.1s linear infinite; filter: drop-shadow(0 0 6px rgba(101,240,173,0.72)); opacity: 1; }
|
|
73
|
+
.edge-line.is-muted { opacity: 0.24; }
|
|
68
74
|
.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
75
|
.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
76
|
.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); }
|
|
77
|
+
button.service-node { cursor: pointer; color: inherit; font: inherit; text-align: left; }
|
|
78
|
+
.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); }
|
|
79
|
+
.service-node.is-selected { z-index: 27; }
|
|
71
80
|
.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
81
|
.logo img { width: 28px; height: 28px; object-fit: contain; filter: invert(1); }
|
|
73
82
|
.service-copy { min-width: 0; }
|
|
@@ -80,10 +89,49 @@ function pageCss() {
|
|
|
80
89
|
.legend i { display: inline-block; width: 8px; height: 8px; margin-right: 8px; border-radius: 999px; }
|
|
81
90
|
.scan-foot { position: absolute; right: 32px; bottom: 32px; z-index: 20; color: #53665d; text-align: right; font-size: 12px; line-height: 1.6; }
|
|
82
91
|
.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; }
|
|
92
|
+
.drawer-scrim { position: absolute; inset: 0; z-index: 24; background: rgba(0,0,0,0.58); pointer-events: none; }
|
|
93
|
+
.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); }
|
|
94
|
+
.details-head { display: flex; align-items: center; gap: 16px; padding: 26px 26px 22px; border-bottom: 1px solid rgba(255,255,255,0.08); }
|
|
95
|
+
.details-title { min-width: 0; flex: 1; }
|
|
96
|
+
.details-title span { display: block; color: #83978d; font-size: 13px; }
|
|
97
|
+
.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; }
|
|
98
|
+
.close-button, .link-button, .delete-button, .save-button { border: 0; cursor: pointer; font: inherit; }
|
|
99
|
+
.close-button { width: 36px; height: 36px; border-radius: 999px; background: transparent; color: #91a59b; font-size: 26px; }
|
|
100
|
+
.details-body { height: calc(100% - 100px); overflow: auto; padding: 26px; }
|
|
101
|
+
.details-section { padding: 0 0 28px; margin: 0 0 28px; border-bottom: 1px solid rgba(255,255,255,0.08); }
|
|
102
|
+
.details-section:last-child { border-bottom: 0; margin-bottom: 0; }
|
|
103
|
+
.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; }
|
|
104
|
+
.field-label { display: block; margin: 16px 0 8px; color: #83978d; font-size: 13px; }
|
|
105
|
+
.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; }
|
|
106
|
+
.details-textarea { min-height: 96px; resize: vertical; line-height: 1.45; }
|
|
107
|
+
.details-input:focus, .details-textarea:focus { border-color: #65f0ad; box-shadow: 0 0 0 2px rgba(101,240,173,0.16); }
|
|
108
|
+
.links-list { display: grid; gap: 12px; }
|
|
109
|
+
.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; }
|
|
110
|
+
.link-card strong { color: #eef7f2; font-size: 14px; }
|
|
111
|
+
.link-card span { grid-column: 1 / -1; color: #74867d; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
112
|
+
.link-button { color: #8dc9ae; background: transparent; padding: 4px; }
|
|
113
|
+
.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); }
|
|
114
|
+
.delete-button { color: #ff6b57; background: transparent; font-weight: 800; }
|
|
115
|
+
.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); }
|
|
116
|
+
.helper-text { margin: 10px 0 0; color: #687c72; font-size: 12px; line-height: 1.45; }
|
|
117
|
+
.three-scene { position: absolute; inset: 0; perspective: 1200px; overflow: hidden; cursor: grab; touch-action: none; }
|
|
118
|
+
.three-scene:active { cursor: grabbing; }
|
|
119
|
+
.three-space { position: absolute; left: 50%; top: 52%; width: min(1040px, 78vw); height: min(700px, 64vh); transform-style: preserve-3d; transform: translate(-50%, -50%) rotateX(var(--rx, 14deg)) rotateZ(var(--rz, 0deg)); transition: transform 140ms ease; pointer-events: none; }
|
|
120
|
+
.three-link { position: absolute; left: 50%; top: 50%; width: var(--length); height: 1px; transform-origin: 0 0; transform: rotateZ(var(--angle)); background: linear-gradient(90deg, rgba(101,240,173,0.28), rgba(101,240,173,0.05)); opacity: 0.72; }
|
|
121
|
+
.three-link.is-active { height: 2px; background: repeating-linear-gradient(90deg, #65f0ad 0 8px, transparent 8px 14px); box-shadow: 0 0 14px rgba(101,240,173,0.68); animation: servicemapLinkFlow 1s linear infinite; }
|
|
122
|
+
.three-node { position: absolute; left: 50%; top: 50%; width: 124px; min-height: 72px; transform: translate3d(var(--x), var(--y), var(--z)) translate(-50%, -50%) scale(var(--scale)); border: 0; border-radius: 999px; background: transparent; color: #eaf8f1; cursor: pointer; font: inherit; text-align: center; pointer-events: auto; }
|
|
123
|
+
.three-orb { display: grid; place-items: center; width: 30px; height: 30px; margin: 0 auto 6px; border-radius: 999px; background: var(--color); color: #fff; box-shadow: 0 0 28px color-mix(in srgb, var(--color), transparent 46%); font-size: 10px; font-weight: 900; }
|
|
124
|
+
.three-node span { display: block; max-width: 124px; margin: 0 auto; color: #e5f1ea; font-size: 10px; font-weight: 800; line-height: 1.12; text-shadow: 0 2px 8px #000; }
|
|
125
|
+
.three-node.is-selected .three-orb { outline: 2px solid #65f0ad; outline-offset: 4px; box-shadow: 0 0 34px rgba(101,240,173,0.72); }
|
|
126
|
+
.three-core { position: absolute; left: 50%; top: 50%; display: grid; place-items: center; width: 54px; height: 54px; transform: translate(-50%, -50%); border-radius: 999px; background: #f7faf7; color: #101611; font-size: 22px; box-shadow: 0 0 0 9px rgba(101,240,173,0.12), 0 0 46px rgba(101,240,173,0.2); }
|
|
127
|
+
.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; }
|
|
128
|
+
@keyframes servicemapDash { to { stroke-dashoffset: -1.3; } }
|
|
129
|
+
@keyframes servicemapLinkFlow { to { background-position: 22px 0; } }
|
|
83
130
|
@media (max-width: 920px) {
|
|
84
131
|
.map { min-height: 1120px; }
|
|
85
132
|
.service-node { width: 220px; }
|
|
86
133
|
.top-actions { display: none; }
|
|
134
|
+
.details-panel { position: fixed; inset: auto 12px 12px; top: 110px; width: auto; }
|
|
87
135
|
}
|
|
88
136
|
`;
|
|
89
137
|
}
|
|
@@ -107,6 +155,11 @@ function initials(name) {
|
|
|
107
155
|
return name.split(/\s+/).filter(Boolean).slice(0, 2).map((part) => part[0]).join('').toUpperCase();
|
|
108
156
|
}
|
|
109
157
|
|
|
158
|
+
function projectMarkHtml(graph) {
|
|
159
|
+
const logoUrl = graph.project?.logoUrl;
|
|
160
|
+
return logoUrl ? `<img src="${escapeHtml(logoUrl)}" alt="${escapeHtml(graph.project?.name ?? 'Project')} logo">` : '⌘';
|
|
161
|
+
}
|
|
162
|
+
|
|
110
163
|
function htmlBody(graph) {
|
|
111
164
|
const services = graph.services ?? [];
|
|
112
165
|
const access = graph.access ?? {};
|
|
@@ -120,6 +173,7 @@ function htmlBody(graph) {
|
|
|
120
173
|
color: palette[index % palette.length],
|
|
121
174
|
}));
|
|
122
175
|
const edges = nodes.map((node) => `<path d="${edgePath(node)}" fill="none" stroke="rgba(103, 130, 118, 0.48)" stroke-width="0.16" vector-effect="non-scaling-stroke"></path>`).join('');
|
|
176
|
+
const projectMark = projectMarkHtml(graph);
|
|
123
177
|
const serviceNodes = nodes.map((service) => `
|
|
124
178
|
<article class="service-node" style="left:${service.x}%;top:${service.y}%">
|
|
125
179
|
<div class="logo" style="background:${service.color}">${service.iconUrl ? `<img src="${escapeHtml(service.iconUrl)}" alt="">` : escapeHtml(initials(service.name))}</div>
|
|
@@ -135,7 +189,7 @@ function htmlBody(graph) {
|
|
|
135
189
|
<main class="servicemap-shell">
|
|
136
190
|
<header class="servicemap-topbar">
|
|
137
191
|
<div class="brand">
|
|
138
|
-
<div class="brand-mark"
|
|
192
|
+
<div class="brand-mark">${projectMark}</div>
|
|
139
193
|
<div>
|
|
140
194
|
<h1>servicemap</h1>
|
|
141
195
|
<p>${escapeHtml(graph.project?.name ?? 'Project')} command center</p>
|
|
@@ -143,7 +197,10 @@ function htmlBody(graph) {
|
|
|
143
197
|
</div>
|
|
144
198
|
<div class="top-actions">
|
|
145
199
|
<span>${services.length} services</span>
|
|
146
|
-
<span class="mode-
|
|
200
|
+
<span class="mode-toggle" aria-label="View mode">
|
|
201
|
+
<span class="mode-button is-active">2D</span>
|
|
202
|
+
<span class="mode-button">3D</span>
|
|
203
|
+
</span>
|
|
147
204
|
</div>
|
|
148
205
|
</header>
|
|
149
206
|
<section class="map">
|
|
@@ -152,7 +209,7 @@ function htmlBody(graph) {
|
|
|
152
209
|
<span>Select a service to trace its dependencies</span>
|
|
153
210
|
</div>
|
|
154
211
|
<svg class="edges" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">${edges}</svg>
|
|
155
|
-
<div class="hub"><div class="hub-inner"
|
|
212
|
+
<div class="hub"><div class="hub-inner">${projectMark}</div></div>
|
|
156
213
|
${serviceNodes || '<div class="intro"><span>No external services were detected yet.</span></div>'}
|
|
157
214
|
${accessNotice}
|
|
158
215
|
<div class="legend">
|
|
@@ -185,6 +242,370 @@ ${htmlBody(graph)}
|
|
|
185
242
|
`;
|
|
186
243
|
}
|
|
187
244
|
|
|
245
|
+
function interactiveTsxPage(graph) {
|
|
246
|
+
return `'use client';
|
|
247
|
+
|
|
248
|
+
import { useMemo, useState, type CSSProperties } from 'react';
|
|
249
|
+
|
|
250
|
+
const css = ${JSON.stringify(pageCss())};
|
|
251
|
+
type Evidence = { type?: string; value?: string; confidence?: string };
|
|
252
|
+
type RawService = { id: string; name: string; key?: string; category?: string; confidence?: string; evidence?: Evidence[]; iconUrl?: string | null };
|
|
253
|
+
type ServiceNode = RawService & { color: string; x: number; y: number };
|
|
254
|
+
type DraftDetails = { subtitle: string; use: string; account: string; passwordLocation: string };
|
|
255
|
+
type ServiceLink = { label: string; url: string };
|
|
256
|
+
|
|
257
|
+
const graph = ${JSON.stringify(graph)} as { generatedAt: string; project?: { name?: string; logoUrl?: string }; services?: RawService[] };
|
|
258
|
+
const palette = ${JSON.stringify(palette)} as string[];
|
|
259
|
+
const layout = ${JSON.stringify(layout)} as Array<{ x: number; y: number }>;
|
|
260
|
+
|
|
261
|
+
function categoryLabel(value?: string | null) {
|
|
262
|
+
return String(value ?? 'service').replace(/-/g, ' ');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function initials(name: string) {
|
|
266
|
+
return name.split(/\\s+/).filter(Boolean).slice(0, 2).map((part) => part[0]).join('').toUpperCase();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function edgePath(node: ServiceNode) {
|
|
270
|
+
const isLeft = node.x < 50;
|
|
271
|
+
const fromX = isLeft ? node.x + 8 : node.x - 8;
|
|
272
|
+
const endX = isLeft ? 47 : 53;
|
|
273
|
+
const midX = isLeft ? Math.max(fromX + 7, 42) : Math.min(fromX - 7, 58);
|
|
274
|
+
return \`M \${fromX} \${node.y} H \${midX} V 50 H \${endX}\`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function threePosition(index: number) {
|
|
278
|
+
const constellation = [
|
|
279
|
+
{ x: 0, y: -240, z: 46 },
|
|
280
|
+
{ x: 230, y: -135, z: 24 },
|
|
281
|
+
{ x: -220, y: -120, z: 10 },
|
|
282
|
+
{ x: 165, y: 88, z: 16 },
|
|
283
|
+
{ x: -170, y: 118, z: -10 },
|
|
284
|
+
{ x: 365, y: 42, z: 34 },
|
|
285
|
+
{ x: -360, y: 52, z: -18 },
|
|
286
|
+
{ x: 265, y: 250, z: -32 },
|
|
287
|
+
{ x: -265, y: -270, z: 28 },
|
|
288
|
+
{ x: 430, y: -190, z: -20 },
|
|
289
|
+
{ x: -430, y: 232, z: 22 },
|
|
290
|
+
{ x: 40, y: 312, z: -38 },
|
|
291
|
+
{ x: -60, y: -350, z: 18 },
|
|
292
|
+
{ x: 520, y: 126, z: 8 },
|
|
293
|
+
{ x: -520, y: -36, z: -26 },
|
|
294
|
+
{ x: 350, y: -330, z: 30 },
|
|
295
|
+
];
|
|
296
|
+
const fallbackAngle = index * 2.399963229728653;
|
|
297
|
+
const fallbackRadius = 250 + (index % 5) * 52;
|
|
298
|
+
const point = constellation[index] ?? {
|
|
299
|
+
x: Math.cos(fallbackAngle) * fallbackRadius,
|
|
300
|
+
y: Math.sin(fallbackAngle) * fallbackRadius * 0.72,
|
|
301
|
+
z: ((index % 7) - 3) * 14,
|
|
302
|
+
};
|
|
303
|
+
const angle = Math.atan2(point.y, point.x);
|
|
304
|
+
const length = Math.hypot(point.x, point.y);
|
|
305
|
+
const scale = 0.74 + Math.max(-36, Math.min(48, point.z)) / 220;
|
|
306
|
+
return {
|
|
307
|
+
x: point.x,
|
|
308
|
+
y: point.y,
|
|
309
|
+
z: point.z,
|
|
310
|
+
angle: (angle * 180) / Math.PI,
|
|
311
|
+
length,
|
|
312
|
+
scale,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function threeNodeStyle(index: number, color: string): CSSProperties {
|
|
317
|
+
const position = threePosition(index);
|
|
318
|
+
return {
|
|
319
|
+
'--x': \`\${position.x}px\`,
|
|
320
|
+
'--y': \`\${position.y}px\`,
|
|
321
|
+
'--z': \`\${position.z}px\`,
|
|
322
|
+
'--scale': String(position.scale),
|
|
323
|
+
'--color': color,
|
|
324
|
+
} as CSSProperties;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function threeLinkStyle(index: number): CSSProperties {
|
|
328
|
+
const position = threePosition(index);
|
|
329
|
+
return {
|
|
330
|
+
'--angle': \`\${position.angle}deg\`,
|
|
331
|
+
'--length': \`\${position.length}px\`,
|
|
332
|
+
'--z': \`\${position.z}px\`,
|
|
333
|
+
} as CSSProperties;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function primaryEvidence(service: RawService) {
|
|
337
|
+
const evidence = service.evidence?.[0];
|
|
338
|
+
return evidence ? \`\${evidence.type}: \${evidence.value}\` : 'No evidence captured yet.';
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function serviceLinks(service: RawService): ServiceLink[] {
|
|
342
|
+
const lower = service.name.toLowerCase();
|
|
343
|
+
const links: ServiceLink[] = [];
|
|
344
|
+
if (service.iconUrl) links.push({ label: \`\${service.name} docs\`, url: service.iconUrl.replace('/icons/', '/') });
|
|
345
|
+
if (lower.includes('firebase')) links.push({ label: 'Firebase console', url: 'https://console.firebase.google.com/' });
|
|
346
|
+
if (lower.includes('google')) links.push({ label: 'Google Cloud console', url: 'https://console.cloud.google.com/' });
|
|
347
|
+
if (lower.includes('cloudflare')) links.push({ label: 'Cloudflare dashboard', url: 'https://dash.cloudflare.com/' });
|
|
348
|
+
if (lower.includes('github')) links.push({ label: 'GitHub', url: 'https://github.com/' });
|
|
349
|
+
if (lower.includes('stripe')) links.push({ label: 'Stripe dashboard', url: 'https://dashboard.stripe.com/' });
|
|
350
|
+
if (lower.includes('vercel')) links.push({ label: 'Vercel dashboard', url: 'https://vercel.com/dashboard' });
|
|
351
|
+
if (lower.includes('microsoft')) links.push({ label: 'Microsoft Azure portal', url: 'https://portal.azure.com/' });
|
|
352
|
+
if (links.length === 0) links.push({ label: \`\${service.name} website\`, url: \`https://www.google.com/search?q=\${encodeURIComponent(service.name)}\` });
|
|
353
|
+
return links.slice(0, 4);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function ServiceLogo({ service }: { service: RawService & { color: string } }) {
|
|
357
|
+
return (
|
|
358
|
+
<div className="logo" style={{ background: service.color }}>
|
|
359
|
+
{service.iconUrl ? (
|
|
360
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
361
|
+
<img src={service.iconUrl} alt="" />
|
|
362
|
+
) : (
|
|
363
|
+
<span>{initials(service.name)}</span>
|
|
364
|
+
)}
|
|
365
|
+
</div>
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function ProjectMark() {
|
|
370
|
+
return graph.project?.logoUrl ? (
|
|
371
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
372
|
+
<img src={graph.project.logoUrl} alt={\`\${graph.project?.name ?? 'Project'} logo\`} />
|
|
373
|
+
) : (
|
|
374
|
+
<>⌘</>
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function DetailsPanel({
|
|
379
|
+
service,
|
|
380
|
+
details,
|
|
381
|
+
onChange,
|
|
382
|
+
onClose,
|
|
383
|
+
}: {
|
|
384
|
+
service: ServiceNode;
|
|
385
|
+
details: DraftDetails;
|
|
386
|
+
onChange: (details: DraftDetails) => void;
|
|
387
|
+
onClose: () => void;
|
|
388
|
+
}) {
|
|
389
|
+
const links = serviceLinks(service);
|
|
390
|
+
return (
|
|
391
|
+
<>
|
|
392
|
+
<div className="drawer-scrim" />
|
|
393
|
+
<aside className="details-panel" aria-label={\`\${service.name} details\`}>
|
|
394
|
+
<div className="details-head">
|
|
395
|
+
<ServiceLogo service={service} />
|
|
396
|
+
<div className="details-title">
|
|
397
|
+
<span>{categoryLabel(service.category)}</span>
|
|
398
|
+
<strong>{service.name}</strong>
|
|
399
|
+
</div>
|
|
400
|
+
<button type="button" className="close-button" onClick={onClose} aria-label="Close details">×</button>
|
|
401
|
+
</div>
|
|
402
|
+
|
|
403
|
+
<div className="details-body">
|
|
404
|
+
<section className="details-section">
|
|
405
|
+
<div className="section-heading">Service details</div>
|
|
406
|
+
<label className="field-label" htmlFor="service-subtitle">Service subtitle</label>
|
|
407
|
+
<input
|
|
408
|
+
id="service-subtitle"
|
|
409
|
+
className="details-input"
|
|
410
|
+
value={details.subtitle}
|
|
411
|
+
onChange={(event) => onChange({ ...details, subtitle: event.target.value })}
|
|
412
|
+
/>
|
|
413
|
+
|
|
414
|
+
<label className="field-label" htmlFor="service-use">How it is used</label>
|
|
415
|
+
<textarea
|
|
416
|
+
id="service-use"
|
|
417
|
+
className="details-textarea"
|
|
418
|
+
value={details.use}
|
|
419
|
+
onChange={(event) => onChange({ ...details, use: event.target.value })}
|
|
420
|
+
/>
|
|
421
|
+
</section>
|
|
422
|
+
|
|
423
|
+
<section className="details-section">
|
|
424
|
+
<div className="section-heading">
|
|
425
|
+
<span>Relevant links</span>
|
|
426
|
+
<span>+ Add</span>
|
|
427
|
+
</div>
|
|
428
|
+
<div className="links-list">
|
|
429
|
+
{links.map((link) => (
|
|
430
|
+
<div className="link-card" key={link.label}>
|
|
431
|
+
<strong>{link.label}</strong>
|
|
432
|
+
<a className="link-button" href={link.url} target="_blank" rel="noreferrer">Open</a>
|
|
433
|
+
<span>{link.url}</span>
|
|
434
|
+
</div>
|
|
435
|
+
))}
|
|
436
|
+
</div>
|
|
437
|
+
</section>
|
|
438
|
+
|
|
439
|
+
<section className="details-section">
|
|
440
|
+
<div className="section-heading">Account</div>
|
|
441
|
+
<label className="field-label" htmlFor="account-owner">Account owner or email</label>
|
|
442
|
+
<input
|
|
443
|
+
id="account-owner"
|
|
444
|
+
className="details-input"
|
|
445
|
+
value={details.account}
|
|
446
|
+
onChange={(event) => onChange({ ...details, account: event.target.value })}
|
|
447
|
+
placeholder="owner@example.com"
|
|
448
|
+
/>
|
|
449
|
+
|
|
450
|
+
<label className="field-label" htmlFor="password-location">Password / vault item</label>
|
|
451
|
+
<input
|
|
452
|
+
id="password-location"
|
|
453
|
+
className="details-input"
|
|
454
|
+
value={details.passwordLocation}
|
|
455
|
+
onChange={(event) => onChange({ ...details, passwordLocation: event.target.value })}
|
|
456
|
+
placeholder="1Password item, vault record, or SSO note"
|
|
457
|
+
/>
|
|
458
|
+
<p className="helper-text">Keep actual passwords in your password manager. Store only the vault item or access note here.</p>
|
|
459
|
+
</section>
|
|
460
|
+
</div>
|
|
461
|
+
|
|
462
|
+
<div className="details-actions">
|
|
463
|
+
<button type="button" className="delete-button">Delete</button>
|
|
464
|
+
<button type="button" className="save-button">Save changes</button>
|
|
465
|
+
</div>
|
|
466
|
+
</aside>
|
|
467
|
+
</>
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export default function InternalServiceMapPage() {
|
|
472
|
+
const nodes = useMemo<ServiceNode[]>(() => (graph.services ?? []).slice(0, layout.length).map((service, index) => ({
|
|
473
|
+
...service,
|
|
474
|
+
...layout[index],
|
|
475
|
+
color: palette[index % palette.length],
|
|
476
|
+
})), []);
|
|
477
|
+
const [mode, setMode] = useState<'2d' | '3d'>('2d');
|
|
478
|
+
const [rotation, setRotation] = useState({ x: 14, z: 0 });
|
|
479
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
480
|
+
const [drafts, setDrafts] = useState<Record<string, DraftDetails>>(() => Object.fromEntries(nodes.map((service) => [
|
|
481
|
+
service.id,
|
|
482
|
+
{
|
|
483
|
+
subtitle: categoryLabel(service.category),
|
|
484
|
+
use: primaryEvidence(service),
|
|
485
|
+
account: '',
|
|
486
|
+
passwordLocation: '',
|
|
487
|
+
},
|
|
488
|
+
])));
|
|
489
|
+
const selected = nodes.find((service) => service.id === selectedId) ?? null;
|
|
490
|
+
|
|
491
|
+
return (
|
|
492
|
+
<main className="servicemap-shell">
|
|
493
|
+
<style dangerouslySetInnerHTML={{ __html: css }} />
|
|
494
|
+
<header className="servicemap-topbar">
|
|
495
|
+
<div className="brand">
|
|
496
|
+
<div className="brand-mark"><ProjectMark /></div>
|
|
497
|
+
<div>
|
|
498
|
+
<h1>servicemap</h1>
|
|
499
|
+
<p>{graph.project?.name ?? 'Project'} command center</p>
|
|
500
|
+
</div>
|
|
501
|
+
</div>
|
|
502
|
+
<div className="top-actions">
|
|
503
|
+
<span>{nodes.length} services</span>
|
|
504
|
+
<div className="mode-toggle" aria-label="View mode">
|
|
505
|
+
<button type="button" className={\`mode-button \${mode === '2d' ? 'is-active' : ''}\`} onClick={() => setMode('2d')}>2D</button>
|
|
506
|
+
<button type="button" className={\`mode-button \${mode === '3d' ? 'is-active' : ''}\`} onClick={() => setMode('3d')}>3D</button>
|
|
507
|
+
</div>
|
|
508
|
+
</div>
|
|
509
|
+
</header>
|
|
510
|
+
|
|
511
|
+
<section className="map">
|
|
512
|
+
<div className="intro">
|
|
513
|
+
<strong>{graph.project?.name ?? 'Project'} infrastructure</strong>
|
|
514
|
+
<span>{selected ? \`Tracing \${selected.name}\` : mode === '3d' ? 'Rotate and explore your service relationships' : 'Select a service to trace its dependencies'}</span>
|
|
515
|
+
</div>
|
|
516
|
+
{mode === '2d' ? (
|
|
517
|
+
<>
|
|
518
|
+
<svg className="edges" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
|
519
|
+
{nodes.map((service) => (
|
|
520
|
+
<path
|
|
521
|
+
key={service.id}
|
|
522
|
+
className={\`edge-line \${selectedId === service.id ? 'is-active' : selectedId ? 'is-muted' : ''}\`}
|
|
523
|
+
d={edgePath(service)}
|
|
524
|
+
fill="none"
|
|
525
|
+
stroke={selectedId === service.id ? '#65f0ad' : 'rgba(103, 130, 118, 0.48)'}
|
|
526
|
+
strokeWidth={selectedId === service.id ? '0.28' : '0.16'}
|
|
527
|
+
strokeDasharray={selectedId === service.id ? '0.72 0.58' : undefined}
|
|
528
|
+
strokeLinecap={selectedId === service.id ? 'round' : undefined}
|
|
529
|
+
vectorEffect="non-scaling-stroke"
|
|
530
|
+
/>
|
|
531
|
+
))}
|
|
532
|
+
</svg>
|
|
533
|
+
<div className="hub"><div className="hub-inner"><ProjectMark /></div></div>
|
|
534
|
+
{nodes.map((service) => (
|
|
535
|
+
<button
|
|
536
|
+
type="button"
|
|
537
|
+
key={service.id}
|
|
538
|
+
className={\`service-node \${selectedId === service.id ? 'is-selected' : ''}\`}
|
|
539
|
+
style={{ left: \`\${service.x}%\`, top: \`\${service.y}%\` }}
|
|
540
|
+
onClick={() => setSelectedId(service.id)}
|
|
541
|
+
>
|
|
542
|
+
<ServiceLogo service={service} />
|
|
543
|
+
<div className="service-copy">
|
|
544
|
+
<strong>{service.name}</strong>
|
|
545
|
+
<span>{categoryLabel(service.category)}</span>
|
|
546
|
+
</div>
|
|
547
|
+
<i className={\`status-dot status-\${service.confidence}\`} />
|
|
548
|
+
</button>
|
|
549
|
+
))}
|
|
550
|
+
<div className="legend">
|
|
551
|
+
<span><i style={{ background: '#4a7cff' }} />Cloud</span>
|
|
552
|
+
<span><i style={{ background: '#8be4b2' }} />Product</span>
|
|
553
|
+
<span><i style={{ background: '#65f0ad' }} />Active path</span>
|
|
554
|
+
</div>
|
|
555
|
+
</>
|
|
556
|
+
) : (
|
|
557
|
+
<div className="three-scene" aria-label="3D service graph">
|
|
558
|
+
<div className="three-space" style={{ '--rx': \`\${rotation.x}deg\`, '--rz': \`\${rotation.z}deg\` } as CSSProperties}>
|
|
559
|
+
{nodes.map((service, index) => (
|
|
560
|
+
<span
|
|
561
|
+
key={\`\${service.id}-link\`}
|
|
562
|
+
className={\`three-link \${selectedId === service.id ? 'is-active' : ''}\`}
|
|
563
|
+
style={threeLinkStyle(index)}
|
|
564
|
+
/>
|
|
565
|
+
))}
|
|
566
|
+
<div className="three-core"><ProjectMark /></div>
|
|
567
|
+
{nodes.map((service, index) => (
|
|
568
|
+
<button
|
|
569
|
+
type="button"
|
|
570
|
+
key={service.id}
|
|
571
|
+
className={\`three-node \${selectedId === service.id ? 'is-selected' : ''}\`}
|
|
572
|
+
style={threeNodeStyle(index, service.color)}
|
|
573
|
+
onPointerMove={(event) => {
|
|
574
|
+
if (event.buttons !== 1) return;
|
|
575
|
+
setRotation((current) => ({
|
|
576
|
+
x: Math.max(-8, Math.min(34, current.x - event.movementY * 0.18)),
|
|
577
|
+
z: current.z + event.movementX * 0.16,
|
|
578
|
+
}));
|
|
579
|
+
}}
|
|
580
|
+
onClick={() => setSelectedId(service.id)}
|
|
581
|
+
>
|
|
582
|
+
<span className="three-orb">{initials(service.name)}</span>
|
|
583
|
+
<span>{service.name}</span>
|
|
584
|
+
</button>
|
|
585
|
+
))}
|
|
586
|
+
</div>
|
|
587
|
+
<div className="three-hint">Drag a service to rotate / Click for details</div>
|
|
588
|
+
</div>
|
|
589
|
+
)}
|
|
590
|
+
<footer className="scan-foot">
|
|
591
|
+
<div>Generated {new Date(graph.generatedAt).toLocaleString()}</div>
|
|
592
|
+
<div>{nodes.length} services detected</div>
|
|
593
|
+
</footer>
|
|
594
|
+
{selected && drafts[selected.id] ? (
|
|
595
|
+
<DetailsPanel
|
|
596
|
+
service={selected}
|
|
597
|
+
details={drafts[selected.id]}
|
|
598
|
+
onClose={() => setSelectedId(null)}
|
|
599
|
+
onChange={(nextDetails) => setDrafts((current) => ({ ...current, [selected.id]: nextDetails }))}
|
|
600
|
+
/>
|
|
601
|
+
) : null}
|
|
602
|
+
</section>
|
|
603
|
+
</main>
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
`;
|
|
607
|
+
}
|
|
608
|
+
|
|
188
609
|
function tsxPage(graph) {
|
|
189
610
|
const html = htmlBody(graph);
|
|
190
611
|
const allowedEmails = graph.access?.allowedEmails ?? [];
|
|
@@ -217,18 +638,7 @@ export default async function InternalServiceMapPage() {
|
|
|
217
638
|
`;
|
|
218
639
|
}
|
|
219
640
|
|
|
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
|
-
`;
|
|
641
|
+
return interactiveTsxPage(graph);
|
|
232
642
|
}
|
|
233
643
|
|
|
234
644
|
function astroPage(graph) {
|
|
@@ -248,9 +658,27 @@ const html = ${JSON.stringify(htmlBody(graph))};
|
|
|
248
658
|
`;
|
|
249
659
|
}
|
|
250
660
|
|
|
661
|
+
async function prepareProjectLogo(graph, outputRoot) {
|
|
662
|
+
const logoPath = graph.project?.logo?.path;
|
|
663
|
+
if (!logoPath) return;
|
|
664
|
+
|
|
665
|
+
const sourceRoot = graph.project?.root ? resolve(graph.project.root) : outputRoot;
|
|
666
|
+
const sourcePath = isAbsolute(logoPath) ? logoPath : resolve(sourceRoot, logoPath);
|
|
667
|
+
if (!existsSync(sourcePath)) return;
|
|
668
|
+
|
|
669
|
+
const extension = extname(sourcePath) || '.png';
|
|
670
|
+
const assetDir = join(outputRoot, 'public', 'internalservicemap-assets');
|
|
671
|
+
const assetPath = join(assetDir, `project-logo${extension}`);
|
|
672
|
+
await mkdir(assetDir, { recursive: true });
|
|
673
|
+
await copyFile(sourcePath, assetPath);
|
|
674
|
+
|
|
675
|
+
graph.project.logoUrl = `/internalservicemap-assets/project-logo${extension}`;
|
|
676
|
+
}
|
|
677
|
+
|
|
251
678
|
async function main() {
|
|
252
679
|
const options = parseArgs();
|
|
253
680
|
const graph = await readJson(options.input);
|
|
681
|
+
await prepareProjectLogo(graph, options.root);
|
|
254
682
|
const route = routeFor(options.root, graph.project?.framework);
|
|
255
683
|
const contents = route.kind === 'astro' ? astroPage(graph) : route.kind === 'static' ? staticHtml(graph) : tsxPage(graph);
|
|
256
684
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
|
-
import { readFile, readdir,
|
|
3
|
+
import { readFile, readdir, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { basename, dirname, extname, join, relative, resolve } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
|
|
@@ -240,6 +240,59 @@ function buildEdges(projectName, services) {
|
|
|
240
240
|
}));
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
+
async function findProjectLogo(root, files) {
|
|
244
|
+
const appIconManifests = files
|
|
245
|
+
.filter((file) => file.endsWith('AppIcon.appiconset/Contents.json'))
|
|
246
|
+
.sort((a, b) => scoreLogoManifest(b) - scoreLogoManifest(a));
|
|
247
|
+
|
|
248
|
+
for (const manifestPath of appIconManifests) {
|
|
249
|
+
const manifest = await readJson(manifestPath);
|
|
250
|
+
const images = Array.isArray(manifest?.images) ? manifest.images : [];
|
|
251
|
+
const candidates = images
|
|
252
|
+
.filter((image) => image?.filename)
|
|
253
|
+
.map((image) => ({
|
|
254
|
+
file: join(dirname(manifestPath), image.filename),
|
|
255
|
+
score: scoreAppIconImage(image),
|
|
256
|
+
}))
|
|
257
|
+
.filter((candidate) => existsSync(candidate.file))
|
|
258
|
+
.sort((a, b) => b.score - a.score);
|
|
259
|
+
|
|
260
|
+
if (candidates[0]) {
|
|
261
|
+
return {
|
|
262
|
+
path: relative(root, candidates[0].file),
|
|
263
|
+
source: 'asset-catalog-app-icon',
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const namedLogo = files.find((file) => /(^|[/\\])(app-?icon|logo|favicon)\.(png|jpg|jpeg|webp|svg|ico)$/i.test(file));
|
|
269
|
+
if (namedLogo) {
|
|
270
|
+
return {
|
|
271
|
+
path: relative(root, namedLogo),
|
|
272
|
+
source: 'named-image-file',
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function scoreLogoManifest(path) {
|
|
280
|
+
let score = 0;
|
|
281
|
+
if (/Assets\.xcassets[/\\]AppIcon\.appiconset/i.test(path)) score += 10;
|
|
282
|
+
if (/Assets 2\.xcassets/i.test(path)) score += 20;
|
|
283
|
+
return score;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function scoreAppIconImage(image) {
|
|
287
|
+
const size = Number.parseInt(String(image.size ?? '0').split('x')[0], 10) || 0;
|
|
288
|
+
let score = size;
|
|
289
|
+
const filename = String(image.filename ?? '').toLowerCase();
|
|
290
|
+
if (filename.includes('dark') || filename.includes('darker')) score += 100;
|
|
291
|
+
if (filename.includes('tinted')) score -= 50;
|
|
292
|
+
if (image.platform === 'ios') score += 25;
|
|
293
|
+
return score;
|
|
294
|
+
}
|
|
295
|
+
|
|
243
296
|
async function main() {
|
|
244
297
|
const options = parseArgs();
|
|
245
298
|
const packageJsonPath = join(options.root, 'package.json');
|
|
@@ -273,6 +326,7 @@ async function main() {
|
|
|
273
326
|
configFiles: configFiles.map((file) => basename(file)),
|
|
274
327
|
database,
|
|
275
328
|
});
|
|
329
|
+
const logo = await findProjectLogo(options.root, files);
|
|
276
330
|
|
|
277
331
|
const graph = {
|
|
278
332
|
generatedAt: new Date().toISOString(),
|
|
@@ -280,6 +334,7 @@ async function main() {
|
|
|
280
334
|
name: projectName,
|
|
281
335
|
root: options.root,
|
|
282
336
|
framework: detectFramework(options.root, packageJson, files),
|
|
337
|
+
logo,
|
|
283
338
|
},
|
|
284
339
|
access: {
|
|
285
340
|
allowedEmails: options.allowedEmails,
|