@neat.is/web 0.2.10
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/app/api/events/route.ts +54 -0
- package/app/api/graph/blast-radius/[id]/route.ts +19 -0
- package/app/api/graph/node/[id]/route.ts +17 -0
- package/app/api/graph/root-cause/[id]/route.ts +17 -0
- package/app/api/graph/route.ts +12 -0
- package/app/api/health/route.ts +13 -0
- package/app/api/incidents/route.ts +16 -0
- package/app/api/policies/violations/route.ts +11 -0
- package/app/api/projects/route.ts +11 -0
- package/app/api/search/route.ts +17 -0
- package/app/api/stale-events/route.ts +15 -0
- package/app/claude-design/Neat Graph View.html +925 -0
- package/app/claude-design/app.js +604 -0
- package/app/components/AppShell.tsx +109 -0
- package/app/components/GraphCanvas.tsx +607 -0
- package/app/components/Inspector.tsx +329 -0
- package/app/components/Rail.tsx +124 -0
- package/app/components/StatusBar.tsx +72 -0
- package/app/components/TopBar.tsx +211 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +891 -0
- package/app/incidents/page.tsx +145 -0
- package/app/layout.tsx +27 -0
- package/app/page.tsx +5 -0
- package/lib/fixtures.ts +94 -0
- package/lib/proxy.ts +16 -0
- package/package.json +53 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
/* NEAT — graph view runtime
|
|
2
|
+
Renders the sample Bloomberg topology with Cytoscape.js.
|
|
3
|
+
View-only: pan + zoom enabled; node/edge editing disabled.
|
|
4
|
+
*/
|
|
5
|
+
;(function () {
|
|
6
|
+
const data = window.NEAT_GRAPH
|
|
7
|
+
if (!data) {
|
|
8
|
+
console.error('NEAT_GRAPH not loaded')
|
|
9
|
+
return
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// ---------- node-shape / color helpers --------------------------------
|
|
13
|
+
// Map node types to color CSS variables resolved at runtime.
|
|
14
|
+
const cssVar = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
|
15
|
+
|
|
16
|
+
const TYPE_STYLE = {
|
|
17
|
+
service: { color: cssVar('--n-service'), shape: 'round-rectangle', size: 32 },
|
|
18
|
+
db: { color: cssVar('--n-db'), shape: 'barrel', size: 34 },
|
|
19
|
+
cache: { color: cssVar('--n-cache'), shape: 'barrel', size: 28 },
|
|
20
|
+
stream: { color: cssVar('--n-stream'), shape: 'cut-rectangle', size: 32 },
|
|
21
|
+
queue: { color: cssVar('--n-queue'), shape: 'cut-rectangle', size: 28 },
|
|
22
|
+
lambda: { color: cssVar('--n-lambda'), shape: 'diamond', size: 30 },
|
|
23
|
+
cron: { color: cssVar('--n-cron'), shape: 'tag', size: 26 },
|
|
24
|
+
api: { color: cssVar('--n-api'), shape: 'round-rectangle', size: 22 },
|
|
25
|
+
apigw: { color: cssVar('--n-apigw'), shape: 'round-rectangle', size: 36 },
|
|
26
|
+
waf: { color: '#a89898', shape: 'hexagon', size: 28 },
|
|
27
|
+
cdn: { color: '#a8a8b8', shape: 'hexagon', size: 28 },
|
|
28
|
+
lb: { color: '#a8b8b0', shape: 'hexagon', size: 28 },
|
|
29
|
+
compute: { color: cssVar('--n-compute'), shape: 'round-rectangle', size: 32 },
|
|
30
|
+
storage: { color: cssVar('--n-storage'), shape: 'round-tag', size: 28 },
|
|
31
|
+
external: { color: cssVar('--n-external'), shape: 'round-octagon', size: 30 },
|
|
32
|
+
search: { color: cssVar('--n-search'), shape: 'barrel', size: 28 },
|
|
33
|
+
cluster: { color: cssVar('--n-cluster'), shape: 'round-rectangle' },
|
|
34
|
+
namespace: { color: cssVar('--n-namespace'), shape: 'round-rectangle' },
|
|
35
|
+
vpc: { color: cssVar('--n-vpc'), shape: 'round-rectangle' },
|
|
36
|
+
env: { color: cssVar('--n-env'), shape: 'round-rectangle' },
|
|
37
|
+
cloud: { color: '#1d1d22', shape: 'round-rectangle' },
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const provColor = {
|
|
41
|
+
STATIC: cssVar('--prov-static'),
|
|
42
|
+
OBSERVED: cssVar('--prov-observed'),
|
|
43
|
+
INFERRED: cssVar('--prov-inferred'),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------- build elements --------------------------------------------
|
|
47
|
+
const elements = []
|
|
48
|
+
data.nodes.forEach((n) => {
|
|
49
|
+
const t = n.data.type
|
|
50
|
+
const ts = TYPE_STYLE[t] || { color: '#888', shape: 'ellipse', size: 24 }
|
|
51
|
+
elements.push({
|
|
52
|
+
data: {
|
|
53
|
+
...n.data,
|
|
54
|
+
_color: ts.color,
|
|
55
|
+
_shape: ts.shape,
|
|
56
|
+
_size: ts.size || 28,
|
|
57
|
+
_isCompound: ['cloud', 'env', 'vpc', 'cluster', 'namespace'].includes(t),
|
|
58
|
+
},
|
|
59
|
+
classes: `t-${t} ${['cloud', 'env', 'vpc', 'cluster', 'namespace'].includes(t) ? 'compound' : 'leaf'}`,
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
data.edges.forEach((e) => {
|
|
63
|
+
elements.push({
|
|
64
|
+
data: {
|
|
65
|
+
...e.data,
|
|
66
|
+
_color: provColor[e.data.provenance] || '#888',
|
|
67
|
+
_width: e.data.provenance === 'INFERRED' ? 1 : e.data.provenance === 'OBSERVED' ? 1.4 : 1.2,
|
|
68
|
+
_style:
|
|
69
|
+
e.data.provenance === 'INFERRED'
|
|
70
|
+
? 'dotted'
|
|
71
|
+
: e.data.provenance === 'OBSERVED'
|
|
72
|
+
? 'dashed'
|
|
73
|
+
: 'solid',
|
|
74
|
+
_opacity:
|
|
75
|
+
e.data.provenance === 'INFERRED' ? 0.55 : e.data.provenance === 'OBSERVED' ? 0.85 : 0.75,
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
// ---------- Cytoscape -------------------------------------------------
|
|
81
|
+
const cy = cytoscape({
|
|
82
|
+
container: document.getElementById('cy'),
|
|
83
|
+
elements,
|
|
84
|
+
|
|
85
|
+
minZoom: 0.001,
|
|
86
|
+
maxZoom: 50,
|
|
87
|
+
wheelSensitivity: 0.25,
|
|
88
|
+
|
|
89
|
+
// VIEW-ONLY: pan + zoom allowed; nodes locked
|
|
90
|
+
autoungrabify: true, // can't drag nodes
|
|
91
|
+
autounselectify: false, // selection IS allowed (read-only inspector)
|
|
92
|
+
boxSelectionEnabled: false,
|
|
93
|
+
|
|
94
|
+
style: [
|
|
95
|
+
// ---------- compound containers ----------
|
|
96
|
+
{
|
|
97
|
+
selector: 'node.compound',
|
|
98
|
+
style: {
|
|
99
|
+
'background-color': 'data(_color)',
|
|
100
|
+
'background-opacity': 0.35,
|
|
101
|
+
'border-width': 1,
|
|
102
|
+
'border-color': '#2a2a30',
|
|
103
|
+
'border-style': 'solid',
|
|
104
|
+
shape: 'round-rectangle',
|
|
105
|
+
'corner-radius': '4',
|
|
106
|
+
label: 'data(label)',
|
|
107
|
+
'text-valign': 'top',
|
|
108
|
+
'text-halign': 'left',
|
|
109
|
+
'text-margin-x': 8,
|
|
110
|
+
'text-margin-y': 4,
|
|
111
|
+
'font-family': 'JetBrains Mono, monospace',
|
|
112
|
+
'font-size': 10.5,
|
|
113
|
+
color: '#9b968c',
|
|
114
|
+
padding: '24px',
|
|
115
|
+
'text-transform': 'lowercase',
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
selector: 'node.t-cloud',
|
|
120
|
+
style: { 'background-opacity': 0.18, padding: '32px', 'font-size': 11.5, color: '#d8d3c9' },
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
selector: 'node.t-env',
|
|
124
|
+
style: { 'background-opacity': 0.3, padding: '28px', 'font-size': 11, color: '#d8d3c9' },
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
selector: 'node.t-vpc',
|
|
128
|
+
style: { 'background-opacity': 0.4, padding: '22px', 'font-size': 10.5 },
|
|
129
|
+
},
|
|
130
|
+
{ selector: 'node.t-cluster', style: { 'background-opacity': 0.55, padding: '20px' } },
|
|
131
|
+
{ selector: 'node.t-namespace', style: { 'background-opacity': 0.65, padding: '16px' } },
|
|
132
|
+
|
|
133
|
+
// ---------- leaf nodes ----------
|
|
134
|
+
{
|
|
135
|
+
selector: 'node.leaf',
|
|
136
|
+
style: {
|
|
137
|
+
'background-color': 'data(_color)',
|
|
138
|
+
'background-opacity': 0.92,
|
|
139
|
+
shape: 'data(_shape)',
|
|
140
|
+
width: 'data(_size)',
|
|
141
|
+
height: 'data(_size)',
|
|
142
|
+
label: 'data(label)',
|
|
143
|
+
'text-valign': 'bottom',
|
|
144
|
+
'text-halign': 'center',
|
|
145
|
+
'text-margin-y': 5,
|
|
146
|
+
'font-family': 'JetBrains Mono, monospace',
|
|
147
|
+
'font-size': 9.5,
|
|
148
|
+
color: '#d8d3c9',
|
|
149
|
+
'text-outline-width': 2,
|
|
150
|
+
'text-outline-color': '#0a0a0b',
|
|
151
|
+
'border-width': 1,
|
|
152
|
+
'border-color': '#0a0a0b',
|
|
153
|
+
'min-zoomed-font-size': 7,
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
// type-specific tweaks
|
|
157
|
+
{
|
|
158
|
+
selector: 'node.t-api',
|
|
159
|
+
style: { width: 8, height: 8, 'font-size': 8.5, color: '#9b968c' },
|
|
160
|
+
},
|
|
161
|
+
{ selector: 'node.t-cron', style: { width: 18, height: 14 } },
|
|
162
|
+
{
|
|
163
|
+
selector: 'node.t-external',
|
|
164
|
+
style: {
|
|
165
|
+
'background-opacity': 0.7,
|
|
166
|
+
'border-color': '#46443f',
|
|
167
|
+
'border-width': 1,
|
|
168
|
+
'border-style': 'dashed',
|
|
169
|
+
color: '#9b968c',
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
selector: 'node.t-lambda',
|
|
174
|
+
style: { 'background-color': cssVar('--n-lambda'), width: 22, height: 22 },
|
|
175
|
+
},
|
|
176
|
+
{ selector: 'node.t-storage', style: { 'background-color': cssVar('--n-storage') } },
|
|
177
|
+
{ selector: 'node.t-stream', style: { 'background-color': cssVar('--n-stream') } },
|
|
178
|
+
{
|
|
179
|
+
selector: 'node.t-queue',
|
|
180
|
+
style: { 'background-color': cssVar('--n-queue'), width: 20, height: 20 },
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
// selected
|
|
184
|
+
{
|
|
185
|
+
selector: 'node:selected',
|
|
186
|
+
style: {
|
|
187
|
+
'border-color': cssVar('--accent'),
|
|
188
|
+
'border-width': 2,
|
|
189
|
+
'background-opacity': 1,
|
|
190
|
+
color: '#f4efe6',
|
|
191
|
+
'font-weight': 600,
|
|
192
|
+
'z-index': 999,
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
// dim non-neighbors when something is selected
|
|
196
|
+
{
|
|
197
|
+
selector: '.dim',
|
|
198
|
+
style: {
|
|
199
|
+
opacity: 0.18,
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
selector: 'edge.dim',
|
|
204
|
+
style: { opacity: 0.08 },
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
selector: 'node.hl, edge.hl',
|
|
208
|
+
style: { opacity: 1 },
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
selector: 'edge.hl',
|
|
212
|
+
style: {
|
|
213
|
+
width: 'mapData(_width, 0, 2, 1.6, 2.4)',
|
|
214
|
+
opacity: 1,
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
// ---------- edges ----------
|
|
219
|
+
{
|
|
220
|
+
selector: 'edge',
|
|
221
|
+
style: {
|
|
222
|
+
'curve-style': 'bezier',
|
|
223
|
+
'control-point-step-size': 30,
|
|
224
|
+
'line-color': 'data(_color)',
|
|
225
|
+
'line-style': 'data(_style)',
|
|
226
|
+
width: 'data(_width)',
|
|
227
|
+
opacity: 'data(_opacity)',
|
|
228
|
+
'target-arrow-shape': 'triangle-backcurve',
|
|
229
|
+
'target-arrow-color': 'data(_color)',
|
|
230
|
+
'arrow-scale': 0.85,
|
|
231
|
+
'font-family': 'JetBrains Mono, monospace',
|
|
232
|
+
'font-size': 8,
|
|
233
|
+
color: '#6a675f',
|
|
234
|
+
'text-rotation': 'autorotate',
|
|
235
|
+
'text-background-color': '#0a0a0b',
|
|
236
|
+
'text-background-opacity': 1,
|
|
237
|
+
'text-background-padding': 2,
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
// show edge verb only on closer zoom
|
|
242
|
+
selector: 'edge[type]',
|
|
243
|
+
style: { label: 'data(type)', 'min-zoomed-font-size': 11 },
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
// ---------- node-type extras: special apigw/cdn/lb badges ----------
|
|
247
|
+
{
|
|
248
|
+
selector: 'node.t-apigw',
|
|
249
|
+
style: {
|
|
250
|
+
'background-color': cssVar('--n-apigw'),
|
|
251
|
+
shape: 'round-rectangle',
|
|
252
|
+
width: 36,
|
|
253
|
+
height: 22,
|
|
254
|
+
'font-size': 9,
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
],
|
|
258
|
+
|
|
259
|
+
layout: {
|
|
260
|
+
name: 'cose',
|
|
261
|
+
animate: false,
|
|
262
|
+
randomize: true,
|
|
263
|
+
idealEdgeLength: 90,
|
|
264
|
+
nodeRepulsion: 9000,
|
|
265
|
+
edgeElasticity: 80,
|
|
266
|
+
gravity: 0.4,
|
|
267
|
+
numIter: 2200,
|
|
268
|
+
nestingFactor: 1.4,
|
|
269
|
+
componentSpacing: 100,
|
|
270
|
+
padding: 30,
|
|
271
|
+
fit: true,
|
|
272
|
+
},
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
// status bar counts + legend counts
|
|
276
|
+
document.getElementById('st-nodes').textContent = data.nodes.length
|
|
277
|
+
document.getElementById('st-edges').textContent = data.edges.length
|
|
278
|
+
const counts = { STATIC: 0, OBSERVED: 0, INFERRED: 0 }
|
|
279
|
+
data.edges.forEach((e) => {
|
|
280
|
+
counts[e.data.provenance] = (counts[e.data.provenance] || 0) + 1
|
|
281
|
+
})
|
|
282
|
+
document.getElementById('ct-static').textContent = counts.STATIC
|
|
283
|
+
document.getElementById('ct-observed').textContent = counts.OBSERVED
|
|
284
|
+
document.getElementById('ct-inferred').textContent = counts.INFERRED
|
|
285
|
+
// also update canvas tag
|
|
286
|
+
document.querySelector('.canvas-tag .meta').textContent =
|
|
287
|
+
`live · ${data.nodes.length} nodes · ${data.edges.length} edges · cose layout`
|
|
288
|
+
// edges tab count
|
|
289
|
+
// (will be set on selection)
|
|
290
|
+
|
|
291
|
+
// ---------- selection / inspector -------------------------------------
|
|
292
|
+
const inspectBody = document.getElementById('inspect-body')
|
|
293
|
+
|
|
294
|
+
function renderInspector(nodeId) {
|
|
295
|
+
const n = cy.getElementById(nodeId)
|
|
296
|
+
if (!n || n.length === 0 || !n.isNode()) return
|
|
297
|
+
const d = n.data()
|
|
298
|
+
// gather outgoing/incoming
|
|
299
|
+
const out = n.outgoers('edge').map((e) => ({
|
|
300
|
+
verb: e.data('type'),
|
|
301
|
+
target: e.target().data('label') || e.target().id(),
|
|
302
|
+
prov: e.data('provenance'),
|
|
303
|
+
conf: e.data('confidence'),
|
|
304
|
+
}))
|
|
305
|
+
const inc = n.incomers('edge').map((e) => ({
|
|
306
|
+
verb: e.data('type'),
|
|
307
|
+
target: e.source().data('label') || e.source().id(),
|
|
308
|
+
prov: e.data('provenance'),
|
|
309
|
+
conf: e.data('confidence'),
|
|
310
|
+
}))
|
|
311
|
+
|
|
312
|
+
// metadata kvs (only the ones we have)
|
|
313
|
+
const meta = []
|
|
314
|
+
if (d.lang) meta.push(['language', d.lang])
|
|
315
|
+
if (d.replicas) meta.push(['replicas', d.replicas])
|
|
316
|
+
if (d.image) meta.push(['image', d.image])
|
|
317
|
+
if (d.engine) meta.push(['engine', d.engine])
|
|
318
|
+
if (d.version) meta.push(['version', d.version])
|
|
319
|
+
if (d.size) meta.push(['instance', d.size])
|
|
320
|
+
if (d.protocol) meta.push(['protocol', d.protocol])
|
|
321
|
+
if (d.runtime) meta.push(['runtime', d.runtime])
|
|
322
|
+
if (d.mem) meta.push(['memory', d.mem])
|
|
323
|
+
if (d.schedule) meta.push(['schedule', d.schedule])
|
|
324
|
+
if (d.tz) meta.push(['tz', d.tz])
|
|
325
|
+
if (d.kind) meta.push(['kind', d.kind])
|
|
326
|
+
if (d.region) meta.push(['region', d.region])
|
|
327
|
+
if (d.stage) meta.push(['stage', d.stage])
|
|
328
|
+
if (d.k8s_version) meta.push(['k8s', d.k8s_version])
|
|
329
|
+
|
|
330
|
+
const parent = d.parent ? cy.getElementById(d.parent).data('label') : '—'
|
|
331
|
+
|
|
332
|
+
// synthetic metrics so the panel feels alive
|
|
333
|
+
const metricRPS = d.replicas
|
|
334
|
+
? Math.round(d.replicas * 480 + Math.random() * 220)
|
|
335
|
+
: Math.round(40 + Math.random() * 80)
|
|
336
|
+
const metricP99 = (38 + Math.random() * 64).toFixed(1)
|
|
337
|
+
const metricErr = (Math.random() * 0.7).toFixed(2)
|
|
338
|
+
|
|
339
|
+
// type-aware title decoration
|
|
340
|
+
const labelParts = (d.label || d.id).split('/')
|
|
341
|
+
const stem = labelParts.length > 1 ? labelParts[0] + '/' : ''
|
|
342
|
+
const rest = labelParts.length > 1 ? labelParts.slice(1).join('/') : d.label || d.id
|
|
343
|
+
|
|
344
|
+
inspectBody.innerHTML = `
|
|
345
|
+
<section class="insp-section">
|
|
346
|
+
<div class="insp-eyebrow">${escapeHtml(d.type || '').toUpperCase()} · ${escapeHtml(parent)}</div>
|
|
347
|
+
<div class="insp-title"><span class="stem">${escapeHtml(stem)}</span>${escapeHtml(rest)}</div>
|
|
348
|
+
<div class="insp-sub">${escapeHtml(d.id)}</div>
|
|
349
|
+
<div class="insp-tags">
|
|
350
|
+
${d.replicas ? `<span class="tag alive">${d.replicas} replicas</span>` : ''}
|
|
351
|
+
${d.engine ? `<span class="tag">${escapeHtml(d.engine)}</span>` : ''}
|
|
352
|
+
${d.lang ? `<span class="tag">${escapeHtml(d.lang)}</span>` : ''}
|
|
353
|
+
${d.kind ? `<span class="tag">${escapeHtml(d.kind)}</span>` : ''}
|
|
354
|
+
${meta.length === 0 ? `<span class="tag">${escapeHtml(d.type)}</span>` : ''}
|
|
355
|
+
</div>
|
|
356
|
+
</section>
|
|
357
|
+
|
|
358
|
+
${metricsBlock(d.type, metricRPS, metricP99, metricErr)}
|
|
359
|
+
|
|
360
|
+
${
|
|
361
|
+
meta.length
|
|
362
|
+
? `
|
|
363
|
+
<section class="insp-section">
|
|
364
|
+
<div class="insp-h">Properties <span class="ct">${meta.length}</span></div>
|
|
365
|
+
<dl class="kv">
|
|
366
|
+
${meta.map(([k, v]) => `<dt>${escapeHtml(k)}</dt><dd>${escapeHtml(String(v))}</dd>`).join('')}
|
|
367
|
+
</dl>
|
|
368
|
+
</section>`
|
|
369
|
+
: ''
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
<section class="insp-section">
|
|
373
|
+
<div class="insp-h">Outgoing <span class="ct">${out.length}</span></div>
|
|
374
|
+
<ul class="edge-list">
|
|
375
|
+
${out.length ? out.map(edgeRow).join('') : `<li><span class="verb">—</span><span class="target" style="color:var(--paper-3)">no outgoing edges</span></li>`}
|
|
376
|
+
</ul>
|
|
377
|
+
</section>
|
|
378
|
+
|
|
379
|
+
<section class="insp-section">
|
|
380
|
+
<div class="insp-h">Incoming <span class="ct">${inc.length}</span></div>
|
|
381
|
+
<ul class="edge-list">
|
|
382
|
+
${inc.length ? inc.map(edgeRow).join('') : `<li><span class="verb">—</span><span class="target" style="color:var(--paper-3)">no incoming edges</span></li>`}
|
|
383
|
+
</ul>
|
|
384
|
+
</section>
|
|
385
|
+
|
|
386
|
+
<section class="insp-section">
|
|
387
|
+
<div class="insp-h">Provenance summary <span class="ct">${out.length + inc.length}</span></div>
|
|
388
|
+
${provBars(out, inc)}
|
|
389
|
+
</section>
|
|
390
|
+
`
|
|
391
|
+
|
|
392
|
+
// tab edge count
|
|
393
|
+
document.querySelectorAll('.inspect-tab').forEach((t, i) => {
|
|
394
|
+
if (i === 1) {
|
|
395
|
+
const ct = t.querySelector('.ct')
|
|
396
|
+
if (ct) ct.textContent = out.length + inc.length
|
|
397
|
+
}
|
|
398
|
+
})
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function edgeRow(e) {
|
|
402
|
+
return `<li>
|
|
403
|
+
<span class="pdot ${e.prov}"></span>
|
|
404
|
+
<span class="verb">${escapeHtml(e.verb || '').toLowerCase()}</span>
|
|
405
|
+
<span class="target">${escapeHtml(e.target)}</span>
|
|
406
|
+
<span class="conf">${typeof e.conf === 'number' ? e.conf.toFixed(2) : '—'}</span>
|
|
407
|
+
</li>`
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function metricsBlock(type, rps, p99, err) {
|
|
411
|
+
if (!type || ['env', 'vpc', 'cluster', 'namespace', 'cloud', 'external', 'api'].includes(type))
|
|
412
|
+
return ''
|
|
413
|
+
return `<section class="insp-section">
|
|
414
|
+
<div class="metrics">
|
|
415
|
+
<div class="metric"><div class="lbl">req/s</div><div class="val">${rps.toLocaleString()}</div><div class="delta">+${(Math.random() * 4).toFixed(1)}%</div></div>
|
|
416
|
+
<div class="metric"><div class="lbl">p99 ms</div><div class="val">${p99}</div><div class="delta ${parseFloat(p99) > 80 ? 'bad' : ''}">${parseFloat(p99) > 80 ? '+' : '−'}${(Math.random() * 8).toFixed(1)}%</div></div>
|
|
417
|
+
<div class="metric"><div class="lbl">err %</div><div class="val">${err}</div><div class="delta ${parseFloat(err) > 0.4 ? 'bad' : ''}">${parseFloat(err) > 0.4 ? '+' : '−'}${(Math.random() * 0.3).toFixed(2)}</div></div>
|
|
418
|
+
</div>
|
|
419
|
+
</section>`
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function provBars(out, inc) {
|
|
423
|
+
const all = [...out, ...inc]
|
|
424
|
+
const total = all.length || 1
|
|
425
|
+
const c = { STATIC: 0, OBSERVED: 0, INFERRED: 0 }
|
|
426
|
+
all.forEach((e) => {
|
|
427
|
+
c[e.prov] = (c[e.prov] || 0) + 1
|
|
428
|
+
})
|
|
429
|
+
const row = (k) => {
|
|
430
|
+
const pct = (c[k] / total) * 100
|
|
431
|
+
return `<div style="display:flex;align-items:center;gap:8px;font-size:11.5px;margin:5px 0">
|
|
432
|
+
<span class="pdot ${k}" style="width:7px;height:7px;border-radius:50%;background:var(--prov-${k.toLowerCase()})"></span>
|
|
433
|
+
<span class="serif" style="font-style:italic;width:70px;color:var(--paper-2)">${k.toLowerCase()}</span>
|
|
434
|
+
<div style="flex:1;height:4px;background:var(--ink-3);border-radius:2px;overflow:hidden">
|
|
435
|
+
<div style="width:${pct}%;height:100%;background:var(--prov-${k.toLowerCase()})"></div>
|
|
436
|
+
</div>
|
|
437
|
+
<span class="mono" style="font-size:10.5px;color:var(--paper-3);width:34px;text-align:right">${c[k]}</span>
|
|
438
|
+
</div>`
|
|
439
|
+
}
|
|
440
|
+
return row('STATIC') + row('OBSERVED') + row('INFERRED')
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function escapeHtml(s) {
|
|
444
|
+
return String(s).replace(
|
|
445
|
+
/[&<>"']/g,
|
|
446
|
+
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c],
|
|
447
|
+
)
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// selection: highlight neighbors, dim others, render inspector
|
|
451
|
+
function focusNode(id) {
|
|
452
|
+
cy.elements().removeClass('hl dim')
|
|
453
|
+
const n = cy.getElementById(id)
|
|
454
|
+
if (!n || n.length === 0) return
|
|
455
|
+
const neigh = n.neighborhood().add(n)
|
|
456
|
+
cy.elements().not(neigh).addClass('dim')
|
|
457
|
+
neigh.addClass('hl')
|
|
458
|
+
renderInspector(id)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
cy.on('tap', 'node', (evt) => {
|
|
462
|
+
const id = evt.target.id()
|
|
463
|
+
cy.$(':selected').unselect()
|
|
464
|
+
evt.target.select()
|
|
465
|
+
focusNode(id)
|
|
466
|
+
})
|
|
467
|
+
cy.on('tap', (evt) => {
|
|
468
|
+
if (evt.target === cy) {
|
|
469
|
+
cy.elements().removeClass('hl dim')
|
|
470
|
+
cy.$(':selected').unselect()
|
|
471
|
+
// re-render the default
|
|
472
|
+
renderInspector('svc-oms')
|
|
473
|
+
}
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
// initial selection: order management — central, interesting fan-out
|
|
477
|
+
cy.ready(() => {
|
|
478
|
+
setTimeout(() => {
|
|
479
|
+
cy.$id('svc-oms').select()
|
|
480
|
+
focusNode('svc-oms')
|
|
481
|
+
cy.fit(undefined, 40)
|
|
482
|
+
// if fit clamped to minZoom (huge graph), zoom in a touch toward center
|
|
483
|
+
if (cy.zoom() < 0.25) {
|
|
484
|
+
cy.zoom({ level: 0.45, renderedPosition: { x: cy.width() / 2, y: cy.height() / 2 } })
|
|
485
|
+
cy.center(cy.$id('svc-oms'))
|
|
486
|
+
}
|
|
487
|
+
drawMinimap()
|
|
488
|
+
}, 80)
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
// ---------- trackpad two-finger pan -----------------------------------
|
|
492
|
+
// Cytoscape binds its own wheel listener on its inner canvases. We attach
|
|
493
|
+
// ours in the CAPTURE phase on the wrapper so we see the event first and
|
|
494
|
+
// can stop it from reaching Cytoscape's zoom-on-wheel handler.
|
|
495
|
+
const cyEl = document.getElementById('cy')
|
|
496
|
+
const wheelHandler = (e) => {
|
|
497
|
+
if (e.ctrlKey) {
|
|
498
|
+
// pinch-zoom (browser synthesizes ctrlKey for trackpad pinch)
|
|
499
|
+
e.preventDefault()
|
|
500
|
+
e.stopPropagation()
|
|
501
|
+
const factor = Math.exp(-e.deltaY * 0.015)
|
|
502
|
+
const newZoom = Math.max(cy.minZoom(), Math.min(cy.maxZoom(), cy.zoom() * factor))
|
|
503
|
+
const rect = cyEl.getBoundingClientRect()
|
|
504
|
+
cy.zoom({
|
|
505
|
+
level: newZoom,
|
|
506
|
+
renderedPosition: { x: e.clientX - rect.left, y: e.clientY - rect.top },
|
|
507
|
+
})
|
|
508
|
+
} else {
|
|
509
|
+
// two-finger drag → pan
|
|
510
|
+
e.preventDefault()
|
|
511
|
+
e.stopPropagation()
|
|
512
|
+
cy.panBy({ x: -e.deltaX, y: -e.deltaY })
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
cyEl.addEventListener('wheel', wheelHandler, { passive: false, capture: true })
|
|
516
|
+
// also catch on the canvas-wrap in case cy mounts canvases that intercept
|
|
517
|
+
document
|
|
518
|
+
.querySelector('.canvas-wrap')
|
|
519
|
+
.addEventListener('wheel', wheelHandler, { passive: false, capture: true })
|
|
520
|
+
|
|
521
|
+
// ---------- zoom controls ---------------------------------------------
|
|
522
|
+
document.getElementById('z-in').onclick = () =>
|
|
523
|
+
cy.zoom({ level: cy.zoom() * 1.2, renderedPosition: { x: cy.width() / 2, y: cy.height() / 2 } })
|
|
524
|
+
document.getElementById('z-out').onclick = () =>
|
|
525
|
+
cy.zoom({ level: cy.zoom() / 1.2, renderedPosition: { x: cy.width() / 2, y: cy.height() / 2 } })
|
|
526
|
+
document.getElementById('z-fit').onclick = () => cy.fit(undefined, 60)
|
|
527
|
+
|
|
528
|
+
// ---------- minimap ---------------------------------------------------
|
|
529
|
+
const mmCanvas = document.getElementById('minimap-canvas')
|
|
530
|
+
const mmFrame = document.getElementById('minimap-frame')
|
|
531
|
+
function drawMinimap() {
|
|
532
|
+
const dpr = window.devicePixelRatio || 1
|
|
533
|
+
const rect = mmCanvas.getBoundingClientRect()
|
|
534
|
+
mmCanvas.width = rect.width * dpr
|
|
535
|
+
mmCanvas.height = rect.height * dpr
|
|
536
|
+
const ctx = mmCanvas.getContext('2d')
|
|
537
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
|
538
|
+
ctx.clearRect(0, 0, rect.width, rect.height)
|
|
539
|
+
|
|
540
|
+
const bb = cy.elements().boundingBox()
|
|
541
|
+
if (!isFinite(bb.x1)) return
|
|
542
|
+
const pad = 8
|
|
543
|
+
const sx = (rect.width - pad * 2) / (bb.w || 1)
|
|
544
|
+
const sy = (rect.height - pad * 2) / (bb.h || 1)
|
|
545
|
+
const s = Math.min(sx, sy)
|
|
546
|
+
const ox = pad - bb.x1 * s + (rect.width - pad * 2 - bb.w * s) / 2
|
|
547
|
+
const oy = pad - bb.y1 * s + (rect.height - pad * 2 - bb.h * s) / 2
|
|
548
|
+
|
|
549
|
+
// edges (very faint)
|
|
550
|
+
ctx.lineWidth = 0.5
|
|
551
|
+
cy.edges().forEach((e) => {
|
|
552
|
+
const a = e.source().position(),
|
|
553
|
+
b = e.target().position()
|
|
554
|
+
ctx.strokeStyle = e.data('_color') + '55'
|
|
555
|
+
ctx.beginPath()
|
|
556
|
+
ctx.moveTo(a.x * s + ox, a.y * s + oy)
|
|
557
|
+
ctx.lineTo(b.x * s + ox, b.y * s + oy)
|
|
558
|
+
ctx.stroke()
|
|
559
|
+
})
|
|
560
|
+
// nodes
|
|
561
|
+
cy.nodes().forEach((n) => {
|
|
562
|
+
if (n.data('_isCompound')) return
|
|
563
|
+
const p = n.position()
|
|
564
|
+
ctx.fillStyle = n.data('_color') || '#888'
|
|
565
|
+
ctx.beginPath()
|
|
566
|
+
ctx.arc(p.x * s + ox, p.y * s + oy, 1.4, 0, Math.PI * 2)
|
|
567
|
+
ctx.fill()
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
// viewport frame
|
|
571
|
+
const ext = cy.extent() // graph-coord viewport
|
|
572
|
+
const fx = ext.x1 * s + ox
|
|
573
|
+
const fy = ext.y1 * s + oy
|
|
574
|
+
const fw = (ext.x2 - ext.x1) * s
|
|
575
|
+
const fh = (ext.y2 - ext.y1) * s
|
|
576
|
+
mmFrame.style.left = Math.max(0, fx) + 'px'
|
|
577
|
+
mmFrame.style.top = Math.max(0, fy) + 'px'
|
|
578
|
+
mmFrame.style.width = Math.min(rect.width - Math.max(0, fx), fw) + 'px'
|
|
579
|
+
mmFrame.style.height = Math.min(rect.height - Math.max(0, fy), fh) + 'px'
|
|
580
|
+
}
|
|
581
|
+
cy.on('viewport zoom pan render', () => requestAnimationFrame(drawMinimap))
|
|
582
|
+
window.addEventListener('resize', () => requestAnimationFrame(drawMinimap))
|
|
583
|
+
|
|
584
|
+
// ---------- legend filtering ------------------------------------------
|
|
585
|
+
const provFilter = new Set() // empty = show all; otherwise = hidden set
|
|
586
|
+
document.querySelectorAll('.legend-row[data-prov]').forEach((row) => {
|
|
587
|
+
row.addEventListener('click', () => {
|
|
588
|
+
const p = row.dataset.prov
|
|
589
|
+
if (provFilter.has(p)) {
|
|
590
|
+
provFilter.delete(p)
|
|
591
|
+
row.style.opacity = '1'
|
|
592
|
+
} else {
|
|
593
|
+
provFilter.add(p)
|
|
594
|
+
row.style.opacity = '0.4'
|
|
595
|
+
}
|
|
596
|
+
cy.edges().forEach((e) => {
|
|
597
|
+
e.style('display', provFilter.has(e.data('provenance')) ? 'none' : 'element')
|
|
598
|
+
})
|
|
599
|
+
})
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
// expose for debugging
|
|
603
|
+
window.__cy = cy
|
|
604
|
+
})()
|