cloudmap 1.0.0__py3-none-any.whl

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.
@@ -0,0 +1,579 @@
1
+ """Render a graph as ONE self-contained, interactive HTML file.
2
+
3
+ No server, no CDN, no install: the data, the official Azure icons and the whole
4
+ viewer (SVG + vanilla JS + CSS) are inlined, so a developer can open the .html
5
+ straight from disk (file://). That is also what keeps it local-first - nothing is
6
+ fetched at view time.
7
+
8
+ Styled like an architecture diagram, not a web app: white canvas with a light
9
+ grid, each resource is its real Azure icon (the same azure2 set draw.io uses,
10
+ embedded via render/azure_icons.py) with the name beneath it, edges are thin and
11
+ grey. Interactivity on top: pan/zoom, search, click a resource to focus its blast
12
+ radius; a side panel shows each dependency with its relationship, whether it is
13
+ verified or a model guess, and the evidence behind it. Model-proposed edges are
14
+ dashed and red so a guess never looks like a fact.
15
+ """
16
+
17
+ import json
18
+
19
+ from .azure_icons import icon_for
20
+
21
+
22
+ def to_html(graph, seed_id, meta=None):
23
+ meta = meta or {}
24
+ dist = graph.distances or {}
25
+ nodes = [
26
+ {
27
+ "id": n.id,
28
+ "name": n.name,
29
+ "type": n.type,
30
+ "rg": n.resource_group,
31
+ "location": n.location,
32
+ "hops": dist.get(n.id, 0) or 0,
33
+ "external": bool(n.external),
34
+ "note": n.note,
35
+ "seed": n.id == seed_id,
36
+ }
37
+ for n in graph.nodes.values()
38
+ ]
39
+ edges = [
40
+ {"source": e.source, "target": e.target, "kind": e.kind,
41
+ "origin": e.origin, "evidence": e.evidence}
42
+ for e in graph.edges
43
+ ]
44
+ # only the icons of types present in THIS map are embedded
45
+ icons = {}
46
+ for n in graph.nodes.values():
47
+ if not n.external and n.type not in icons:
48
+ svg = icon_for(n.type)
49
+ if svg:
50
+ icons[n.type] = svg
51
+ seed = graph.nodes.get(seed_id)
52
+ data = {
53
+ "seed": seed_id,
54
+ "seedName": seed.name if seed else seed_id,
55
+ "meta": {
56
+ "complete": not (meta.get("truncated") or meta.get("read_gaps")
57
+ or meta.get("blind_spots")),
58
+ "truncated": bool(meta.get("truncated")),
59
+ "read_gaps": list(meta.get("read_gaps") or []),
60
+ "blind_spots": list(meta.get("blind_spots") or []),
61
+ "external": sum(1 for n in graph.nodes.values() if n.external),
62
+ "model_edges": sum(1 for e in graph.edges if e.origin == "model"),
63
+ "nodes": len(graph.nodes),
64
+ "edges": len(graph.edges),
65
+ },
66
+ "nodes": nodes,
67
+ "edges": edges,
68
+ "icons": icons,
69
+ }
70
+ # "<\/" guards against a literal </script> ever appearing inside the data.
71
+ data_json = json.dumps(data).replace("</", "<\\/")
72
+ return _TEMPLATE.replace("/*__DATA__*/null", data_json)
73
+
74
+
75
+ _TEMPLATE = r"""<!doctype html>
76
+ <html lang="en">
77
+ <head>
78
+ <meta charset="utf-8">
79
+ <meta name="viewport" content="width=device-width, initial-scale=1">
80
+ <title>cloudmap</title>
81
+ <style>
82
+ :root{
83
+ --bg: #f3f6f9;
84
+ --card: #ffffff;
85
+ --ink: #1f2328;
86
+ --muted: #57606a;
87
+ --line: #d0d7de;
88
+ --edge: #8c959f;
89
+ --elabel: #424a53;
90
+ --model: #cf222e;
91
+ --accent: #0969da;
92
+ --seed: #bf8700;
93
+ }
94
+ body.dark {
95
+ --bg: #0d1117;
96
+ --card: #161b22;
97
+ --ink: #e6edf3;
98
+ --muted: #7d8590;
99
+ --line: #30363d;
100
+ --edge: #8b949e;
101
+ --elabel: #c9d1d9;
102
+ --model: #ff7b72;
103
+ --accent: #2f81f7;
104
+ --seed: #e3b341;
105
+ }
106
+ * {box-sizing:border-box}
107
+ body{margin:0;padding:0;background:var(--bg);color:var(--ink);font:13px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;height:100vh;display:flex;flex-direction:column;overflow:hidden}
108
+ header{display:flex;align-items:center;gap:14px;flex-wrap:wrap;padding:10px 16px;
109
+ background:var(--card);border-bottom:1px solid var(--line);position:relative;z-index:10}
110
+ header h1{font-size:15px;margin:0;font-weight:600}
111
+ header h1 b{color:var(--accent);font-weight:700}
112
+ header h1 span{color:var(--muted);font-weight:500}
113
+ .search input{background:var(--card);border:1px solid var(--line);color:var(--ink);
114
+ border-radius:6px;padding:5px 10px;width:200px;outline:none;font-size:13px}
115
+ .search input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(9,105,218,.15)}
116
+ .badges{display:flex;gap:7px;flex-wrap:wrap;align-items:center;margin-left:auto}
117
+ .badge{font-size:11.5px;padding:3px 9px;border-radius:999px;background:#f6f8fa;
118
+ color:var(--muted);border:1px solid var(--line)}
119
+ .badge.warn{background:var(--card)8f0;color:#bc4c00;border-color:#f3c795}
120
+ .badge.ok{background:#f0fff4;color:#1a7f37;border-color:#a5d9b3}
121
+ .toggles{display:flex;gap:12px;align-items:center;font-size:12px;color:var(--muted)}
122
+ .toggles label{display:flex;gap:5px;align-items:center;cursor:pointer;user-select:none}
123
+ #stage{display:flex;height:calc(100vh - 47px)}
124
+ #wrap{flex:1;position:relative;overflow:hidden;
125
+ background-image:radial-gradient(var(--grid) 1px,transparent 1px);
126
+ background-size:16px 16px}
127
+ #graph{width:100%;height:100%;cursor:grab;touch-action:none}
128
+ #graph.grabbing{cursor:grabbing}
129
+ .zoom{position:absolute;left:14px;bottom:14px;display:flex;flex-direction:column;
130
+ gap:6px;z-index:6}
131
+ .zoom button{width:32px;height:32px;border-radius:6px;border:1px solid var(--line);
132
+ background:var(--card);color:var(--ink);font-size:16px;cursor:pointer;line-height:1}
133
+ .zoom button:hover{border-color:var(--accent);color:var(--accent)}
134
+ .legend{position:absolute;right:14px;bottom:14px;display:flex;gap:14px;
135
+ background:var(--card);border:1px solid var(--line);border-radius:8px;
136
+ padding:7px 12px;font-size:11.5px;color:var(--muted);z-index:6}
137
+ .lg{display:inline-flex;gap:6px;align-items:center}
138
+ .sw{width:22px;height:0;border-top:1.6px solid var(--edge)}
139
+ .sw.model{border-top:2px dashed var(--model)}
140
+ .sw.box{width:12px;height:12px;border:2px solid var(--seed);border-radius:3px}
141
+ aside{width:340px;background:var(--card);border-left:1px solid var(--line);background:var(--card);
142
+ padding:18px;overflow:auto}
143
+ aside.empty .detail{display:none}
144
+ aside .hint{color:var(--muted)}
145
+ aside h2{font-size:15px;margin:0 0 3px;display:flex;align-items:center;gap:8px}
146
+ aside h2 .hico{width:22px;height:22px;flex:none}
147
+ aside .sub{color:var(--muted);font-size:12px;margin-bottom:14px;word-break:break-all}
148
+ .chip{font-size:10.5px;padding:2px 8px;border-radius:999px;color:#fff;font-weight:600}
149
+ .kv{display:flex;gap:8px;font-size:12.5px;margin:3px 0}
150
+ .kv b{color:var(--muted);font-weight:600;min-width:66px}
151
+ .deps{margin-top:16px}
152
+ .deps h3{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin:0 0 9px}
153
+ .dep{border:1px solid var(--line);border-radius:8px;padding:9px 11px;margin-bottom:9px;background:var(--card)}
154
+ .dep:hover{border-color:#9aa4af}
155
+ .dep .top{display:flex;justify-content:space-between;gap:8px;align-items:center}
156
+ .dep .name{font-weight:600}
157
+ .dep .kind{color:#454f59;font-size:12px;margin-top:2px}
158
+ .dep .ev{color:var(--muted);font-size:11px;margin-top:5px;font-style:italic}
159
+ .arrow{color:var(--muted);font-size:11px}
160
+ .tag{font-size:10px;padding:1px 8px;border-radius:999px;white-space:nowrap;font-weight:600}
161
+ .tag.verified{background:#ddf4ff;color:#0969da}
162
+ .tag.model{background:#ffebe9;color:#cf222e}
163
+ /* graph */
164
+ .edge path{fill:none;stroke:var(--edge);stroke-width:1.4}
165
+ .edge .elabel{fill:var(--elabel);font-size:10.5px;paint-order:stroke;
166
+ stroke:var(--bg);stroke-width:3.5px;font-weight:500}
167
+
168
+ .edge.k-readssecret path { stroke: #d18f00; }
169
+ .edge.k-readssecret .elabel { fill: #b57a00; }
170
+ body.dark .edge.k-readssecret .elabel { fill: #eab446; }
171
+
172
+ .edge.k-connectsto path { stroke: #1a7f37; }
173
+ .edge.k-connectsto .elabel { fill: #1a7f37; }
174
+ body.dark .edge.k-connectsto .elabel { fill: #3fb950; }
175
+
176
+ .edge.k-authenticatesvia path { stroke: #8250df; }
177
+ .edge.k-authenticatesvia .elabel { fill: #8250df; }
178
+ body.dark .edge.k-authenticatesvia .elabel { fill: #bc8cff; }
179
+
180
+ .edge.k-calls path { stroke: #0969da; }
181
+ .edge.k-calls .elabel { fill: #0969da; }
182
+ body.dark .edge.k-calls .elabel { fill: #58a6ff; }
183
+
184
+ .edge.k-pullsimage path { stroke: #bc4c00; }
185
+ .edge.k-pullsimage .elabel { fill: #bc4c00; }
186
+ body.dark .edge.k-pullsimage .elabel { fill: #f78166; }
187
+
188
+ .edge.model path{stroke:var(--model) !important;stroke-dasharray:6 5}
189
+ .edge.model .elabel{fill:var(--model) !important}
190
+ .node{cursor:pointer}
191
+ .node .hit{fill:transparent;stroke:none;rx:8}
192
+ .node:hover .hit{fill:rgba(9,105,218,.06)}
193
+ .node .ring{fill:none;stroke:none;rx:8}
194
+ .node.seed .ring{stroke:var(--seed);stroke-width:2.5}
195
+ .node.sel .ring{stroke:var(--accent);stroke-width:2.5}
196
+ .node .nname{fill:var(--ink);font-weight:600;font-size:12.5px}
197
+ .node .ntype{fill:var(--muted);font-size:10.5px}
198
+ .node .fbox{fill:#dae8fc;stroke:#6c8ebf;stroke-width:1.4;rx:6}
199
+ .node.external .fbox{fill:#f6f8fa;stroke:#8c959f;stroke-dasharray:5 4}
200
+ .node .fbtext{fill:#1f3b57;font-weight:600;font-size:12px}
201
+ .node.external .fbtext{fill:#57606a}
202
+ .dim{opacity:.13 !important}
203
+ .hidden{display:none}
204
+ </style>
205
+ </head>
206
+ <body>
207
+ <header>
208
+ <h1><b>cloudmap</b> <span id="seedName"></span></h1>
209
+ <div class="search"><input id="q" type="search" placeholder="search resources..."></div>
210
+ <select id="rgFilter" style="background:var(--card);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:5px 8px;"><option value="">All Resource Groups</option></select>
211
+ <div class="toggles">
212
+ <label><input type="checkbox" id="tModel" checked> model</label>
213
+ <label><input type="checkbox" id="tExt" checked> external</label>
214
+ </div>
215
+ <button id="btnDark" style="margin-left:auto;background:var(--card);color:var(--ink);border:1px solid var(--line);padding:5px 10px;border-radius:6px;cursor:pointer;">🌙 Dark Mode</button>
216
+ <button id="btnExportPNG" style="margin-left:8px;background:var(--accent);color:#fff;border:none;padding:5px 10px;border-radius:6px;cursor:pointer;font-weight:600;">⬇️ PNG</button>
217
+ <button id="btnExportSVG" style="background:var(--accent);color:#fff;border:none;padding:5px 10px;border-radius:6px;cursor:pointer;font-weight:600;">⬇️ SVG</button>
218
+ <div class="badges" id="badges"></div>
219
+ </header>
220
+ <div id="stage">
221
+ <div id="wrap">
222
+ <svg id="graph" xmlns="http://www.w3.org/2000/svg"><g id="vp"></g></svg>
223
+ <div class="zoom">
224
+ <button id="zin" title="zoom in">+</button>
225
+ <button id="zout" title="zoom out">&minus;</button>
226
+ <button id="zfit" title="fit">&#9634;</button>
227
+ </div>
228
+ <div class="legend">
229
+ <span class="lg"><span class="sw"></span> verified</span>
230
+ <span class="lg"><span class="sw model"></span> model guess</span>
231
+ <span class="lg"><span class="sw box"></span> seed</span>
232
+ </div>
233
+ </div>
234
+ <aside id="panel" class="empty">
235
+ <div class="hint">Click a resource to focus its blast radius. Scroll to zoom, drag to pan.</div>
236
+ <div class="detail"></div>
237
+ </aside>
238
+ </div>
239
+ <script>
240
+ const DATA = /*__DATA__*/null;
241
+ const NS="http://www.w3.org/2000/svg";
242
+ const svg=document.getElementById("graph"), vp=document.getElementById("vp");
243
+ const panel=document.getElementById("panel");
244
+ const byId={}; DATA.nodes.forEach(n=>byId[n.id]=n);
245
+ const esc=s=>String(s==null?"":s).replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
246
+ const shortType=t=>String(t||"").split("/").pop();
247
+ function el(t,a){const e=document.createElementNS(NS,t);for(const k in a)e.setAttribute(k,a[k]);return e;}
248
+ const parser=new DOMParser();
249
+ function iconEl(type,x,y,size){
250
+ const markup=DATA.icons[type];if(!markup)return null;
251
+ const doc=parser.parseFromString(markup,"image/svg+xml");
252
+ const ic=document.importNode(doc.documentElement,true);
253
+ ic.setAttribute("x",x);ic.setAttribute("y",y);
254
+ ic.setAttribute("width",size);ic.setAttribute("height",size);
255
+ return ic;
256
+ }
257
+
258
+ function catInfo(type){
259
+ const t=(type||"").toLowerCase();
260
+ const M=[
261
+ [/sites|serverfarms|managedclusters|containerinstance|functions/,"compute","#2f6feb"],
262
+ [/keyvault|managedidentity|userassigned/,"security","#b58907"],
263
+ [/virtualnetworks|privateendpoints|applicationgateways|apimanagement|networkinterfaces/,"network","#8250df"],
264
+ [/operationalinsights|insights\/components|workspaces/,"monitor","#bf3989"],
265
+ [/servicebus|eventhub|searchservices|cognitiveservices/,"integration","#1b7c83"],
266
+ [/containerregistry|registries/,"registry","#bc4c00"],
267
+ [/sql|postgres|mysql|documentdb|cosmos|cache|redis|storageaccounts/,"data","#1a7f37"],
268
+ ];
269
+ for(const [re,cat,color] of M) if(re.test(t)) return {cat,color};
270
+ return {cat:"other",color:"#6e7781"};
271
+ }
272
+
273
+ document.getElementById("seedName").textContent="— "+DATA.seedName;
274
+ (function(){const m=DATA.meta,b=document.getElementById("badges");
275
+ const add=(txt,cls,title)=>{const s=document.createElement("span");s.className="badge "+(cls||"");s.textContent=txt;if(title)s.title=title;b.appendChild(s);};
276
+ add(m.nodes+" resources");add(m.edges+" dependencies");
277
+ if(m.external)add(m.external+" external","warn");
278
+ if(m.model_edges)add(m.model_edges+" model","warn");
279
+ // why it is incomplete travels with the badge, so the reader never has to guess
280
+ const why=[].concat(m.truncated?["scan hit the pagination cap"]:[],m.read_gaps||[],m.blind_spots||[]);
281
+ add(m.complete?"complete":"INCOMPLETE",m.complete?"ok":"warn",why.join("\n\n"));
282
+ })();
283
+
284
+ // ---- layout: radial blast ----
285
+ // The seed IS the centre of the blast, so draw it that way: dependencies fan out
286
+ // to the RIGHT, dependents to the LEFT, one ring per hop. Angular spans follow a
287
+ // tidy radial tree (each node centred over its subtree), so edges rarely cross,
288
+ // and every edge leaves the seed at its own angle - no bundling, no label pile-up.
289
+ const NW=168,NH=104,ICON=56,PAD=70,RSTEP=270,MINCHORD=190;
290
+ const adjD={},adjU={};
291
+ DATA.edges.forEach(e=>{(adjD[e.source]=adjD[e.source]||[]).push(e.target);
292
+ (adjU[e.target]=adjU[e.target]||[]).push(e.source);});
293
+ // BFS tree from the seed with the same direction-consistency the engine uses.
294
+ const info={};info[DATA.seed]={hops:0,side:"seed",children:[]};
295
+ const bfs=[DATA.seed];
296
+ while(bfs.length){
297
+ const id=bfs.shift(),inf=info[id],steps=[];
298
+ if(inf.side!=="up")(adjD[id]||[]).forEach(t=>steps.push([t,"down"]));
299
+ if(inf.side!=="down")(adjU[id]||[]).forEach(s=>steps.push([s,"up"]));
300
+ steps.forEach(([nb,d])=>{if(info[nb]||!byId[nb])return;
301
+ info[nb]={hops:inf.hops+1,side:d,children:[]};inf.children.push(nb);bfs.push(nb);});
302
+ }
303
+ DATA.nodes.forEach(n=>{if(!info[n.id]){ // safety net: never drop
304
+ info[n.id]={hops:1,side:"down",children:[]};info[DATA.seed].children.push(n.id);}});
305
+ const leafN={};
306
+ (function count(id){const c=info[id].children;
307
+ return leafN[id]=c.length?c.reduce((s,k)=>s+count(k),0):1;})(DATA.seed);
308
+ function spread(kids,a0,a1){
309
+ const tot=kids.reduce((s,k)=>s+leafN[k],0)||1;let a=a0;
310
+ kids.forEach(k=>{const w=(a1-a0)*leafN[k]/tot;
311
+ info[k].angle=a+w/2;spread(info[k].children,a,a+w);a+=w;});
312
+ }
313
+ spread(info[DATA.seed].children.filter(k=>info[k].side==="down"),-76,76);
314
+ spread(info[DATA.seed].children.filter(k=>info[k].side==="up"),104,256);
315
+ // ring radii: grow until neighbours on the ring cannot collide
316
+ const radius={0:0};let rr=0;
317
+ [...new Set(Object.values(info).map(i=>i.hops))].filter(h=>h>0).sort((a,b)=>a-b).forEach(h=>{
318
+ const as=Object.values(info).filter(i=>i.hops===h).map(i=>i.angle).sort((a,b)=>a-b);
319
+ let need=RSTEP;
320
+ for(let i=1;i<as.length;i++){const d=(as[i]-as[i-1])*Math.PI/180;
321
+ if(d>1e-4)need=Math.max(need,MINCHORD/(2*Math.sin(Math.min(d,Math.PI)/2)));}
322
+ rr=Math.max(rr+RSTEP*.85,need);radius[h]=rr;
323
+ });
324
+ // rings are ellipses (wider than tall): labels sit under the icons, screens are
325
+ // wide, and the stretch keeps the fan from becoming a tall oval hugging the seed.
326
+ const STRETCH=1.55;
327
+ const pos={};let mnx=1e9,mny=1e9,mxx=-1e9,mxy=-1e9;
328
+ DATA.nodes.forEach(n=>{const inf=info[n.id];let x=0,y=0;
329
+ if(inf.hops>0){const a=inf.angle*Math.PI/180;
330
+ x=radius[inf.hops]*STRETCH*Math.cos(a);y=radius[inf.hops]*Math.sin(a);}
331
+ pos[n.id]={cx:x,cy:y};
332
+ mnx=Math.min(mnx,x-NW/2);mxx=Math.max(mxx,x+NW/2);
333
+ mny=Math.min(mny,y-NH/2);mxy=Math.max(mxy,y+NH/2);});
334
+ const W=mxx-mnx+PAD*2,H=mxy-mny+PAD*2;
335
+ DATA.nodes.forEach(n=>{const p=pos[n.id];
336
+ p.cx+=PAD-mnx;p.cy+=PAD-mny;p.x=p.cx-NW/2;p.y=p.cy-NH/2;});
337
+
338
+ const defs=el("defs");
339
+ [["arrow","#8b939c"],["arrow-model","#cf222e"]].forEach(([id,col])=>{
340
+ const m=el("marker",{id,viewBox:"0 0 10 10",refX:9,refY:5,markerWidth:7,markerHeight:7,orient:"auto-start-reverse"});
341
+ m.appendChild(el("path",{d:"M0,0 L10,5 L0,10 z",fill:col}));defs.appendChild(m);});
342
+ vp.appendChild(defs);
343
+ const gE=el("g"),gN=el("g");vp.appendChild(gE);vp.appendChild(gN);
344
+
345
+ function anchor(from,to){ // where the line meets the node's box
346
+ const dx=to.cx-from.cx,dy=to.cy-from.cy;
347
+ const t=1/Math.max(Math.abs(dx)/(NW/2-14),Math.abs(dy)/(NH/2+4),1e-6);
348
+ return {x:from.cx+dx*Math.min(t,1),y:from.cy+dy*Math.min(t,1)};
349
+ }
350
+ function edgeGeom(a,b){
351
+ const s=anchor(a,b),t=anchor(b,a);
352
+ const dx=t.x-s.x,dy=t.y-s.y,len=Math.hypot(dx,dy)||1;
353
+ const bow=Math.min(26,len*.07),px=-dy/len*bow,py=dx/len*bow; // gentle arc
354
+ const mx=(s.x+t.x)/2+px,my=(s.y+t.y)/2+py;
355
+ let ang=Math.atan2(dy,dx)*180/Math.PI;
356
+ if(ang>90||ang<-90)ang+=180; // keep the label readable, never upside down
357
+ return {d:`M${s.x},${s.y} Q${mx},${my} ${t.x},${t.y}`,mx:mx-px/2,my:my-py/2,ang};
358
+ }
359
+
360
+ const incident={},edgeG=[];
361
+ DATA.edges.forEach((e,i)=>{
362
+ (incident[e.source]=incident[e.source]||[]).push(i);
363
+ (incident[e.target]=incident[e.target]||[]).push(i);
364
+ const a=pos[e.source],b=pos[e.target];if(!a||!b){edgeG[i]=null;return;}
365
+ const model=e.origin==="model",gm=edgeGeom(a,b);
366
+ const kindCls = e.kind ? " k-"+e.kind.split(" ")[0].toLowerCase().replace(/[^a-z0-9]/g,"") : "";
367
+ const g=el("g",{class:"edge"+(model?" model":"")+kindCls});
368
+ g.appendChild(el("path",{d:gm.d,"marker-end":model?"url(#arrow-model)":"url(#arrow)"}));
369
+ // the label rides its own edge (rotated along it), so labels fan out with the
370
+ // edges instead of piling on one axis; long kinds are trimmed - full text on hover
371
+ const kind=e.kind+(model?" (model)":"");
372
+ const t=el("text",{y:-5,"text-anchor":"middle",class:"elabel",
373
+ transform:`translate(${gm.mx},${gm.my}) rotate(${gm.ang})`});
374
+ t.textContent=kind.length>32?kind.slice(0,30)+"…":kind;
375
+ const tip=el("title");tip.textContent=e.kind+(e.evidence?"\n"+e.evidence:"");
376
+ g.appendChild(tip);g.appendChild(t);
377
+ gE.appendChild(g);edgeG[i]=g;
378
+ });
379
+
380
+ const nodeG={};
381
+ DATA.nodes.forEach(n=>{
382
+ const p=pos[n.id];if(!p)return;
383
+ const g=el("g",{class:"node"+(n.seed?" seed":"")+(n.external?" external":""),
384
+ transform:`translate(${p.x},${p.y})`});
385
+ g.appendChild(el("rect",{class:"hit",width:NW,height:NH,rx:8}));
386
+ const icon=n.external?null:iconEl(n.type,(NW-ICON)/2,4,ICON);
387
+ if(icon){
388
+ g.appendChild(icon);
389
+ const t1=el("text",{x:NW/2,y:ICON+22,class:"nname","text-anchor":"middle"});
390
+ t1.textContent=n.name;g.appendChild(t1);
391
+ const t2=el("text",{x:NW/2,y:ICON+37,class:"ntype","text-anchor":"middle"});
392
+ t2.textContent=shortType(n.type);g.appendChild(t2);
393
+ }else{
394
+ // no icon for this type: a plain diagram box (blue = azure resource,
395
+ // dashed grey = external/unverified reference), never a broken image
396
+ g.appendChild(el("rect",{class:"fbox",x:9,y:22,width:NW-18,height:44,rx:6}));
397
+ const t1=el("text",{x:NW/2,y:48,class:"fbtext","text-anchor":"middle"});
398
+ t1.textContent=n.name.length>22?n.name.slice(0,21)+"…":n.name;g.appendChild(t1);
399
+ const t2=el("text",{x:NW/2,y:80,class:"ntype","text-anchor":"middle"});
400
+ t2.textContent=n.external?"external":shortType(n.type);g.appendChild(t2);
401
+ }
402
+ g.appendChild(el("rect",{class:"ring",x:2,y:2,width:NW-4,height:NH-4,rx:8}));
403
+ const tip=el("title");tip.textContent=n.name+"\n"+n.type;g.appendChild(tip);
404
+ g.addEventListener("pointerdown",ev=>ev.stopPropagation());
405
+ g.addEventListener("click",ev=>{ev.stopPropagation();focus(n.id);});
406
+ gN.appendChild(g);nodeG[n.id]=g;
407
+ });
408
+
409
+ // ---- zoom & pan ----
410
+ let scale=1,tx=0,ty=0;
411
+ function apply(){vp.setAttribute("transform",`translate(${tx},${ty}) scale(${scale})`);}
412
+ function fit(){const r=svg.getBoundingClientRect();
413
+ const s=Math.min(1.2,(r.width-60)/W,(r.height-60)/H)||1;
414
+ scale=s>0?s:1;tx=(r.width-W*scale)/2;ty=Math.max(20,(r.height-H*scale)/2);apply();}
415
+ function zoomAt(mx,my,f){const ns=Math.min(3,Math.max(.2,scale*f));
416
+ tx=mx-(mx-tx)*(ns/scale);ty=my-(my-ty)*(ns/scale);scale=ns;apply();}
417
+ svg.addEventListener("wheel",e=>{e.preventDefault();const r=svg.getBoundingClientRect();
418
+ zoomAt(e.clientX-r.left,e.clientY-r.top,e.deltaY<0?1.12:1/1.12);},{passive:false});
419
+ let drag=false,moved=false,x0,y0,tx0,ty0;
420
+ svg.addEventListener("pointerdown",e=>{drag=true;moved=false;x0=e.clientX;y0=e.clientY;tx0=tx;ty0=ty;svg.classList.add("grabbing");svg.setPointerCapture(e.pointerId);});
421
+ svg.addEventListener("pointermove",e=>{if(!drag)return;const dx=e.clientX-x0,dy=e.clientY-y0;if(Math.abs(dx)+Math.abs(dy)>3)moved=true;tx=tx0+dx;ty=ty0+dy;apply();});
422
+ svg.addEventListener("pointerup",e=>{drag=false;svg.classList.remove("grabbing");if(!moved)clearFocus();});
423
+ document.getElementById("zin").onclick=()=>{const r=svg.getBoundingClientRect();zoomAt(r.width/2,r.height/2,1.2);};
424
+ document.getElementById("zout").onclick=()=>{const r=svg.getBoundingClientRect();zoomAt(r.width/2,r.height/2,1/1.2);};
425
+ document.getElementById("zfit").onclick=fit;
426
+ window.addEventListener("resize",fit);
427
+
428
+ // ---- focus / panel ----
429
+ function focus(id){
430
+ const keepN=new Set([id]),keepE=new Set();
431
+ (incident[id]||[]).forEach(i=>{keepE.add(i);keepN.add(DATA.edges[i].source);keepN.add(DATA.edges[i].target);});
432
+ DATA.nodes.forEach(n=>nodeG[n.id]&&nodeG[n.id].classList.toggle("dim",!keepN.has(n.id)));
433
+ DATA.edges.forEach((e,i)=>edgeG[i]&&edgeG[i].classList.toggle("dim",!keepE.has(i)));
434
+ Object.values(nodeG).forEach(g=>g.classList.remove("sel"));
435
+ nodeG[id]&&nodeG[id].classList.add("sel");
436
+ showPanel(id);
437
+ }
438
+ function clearFocus(){
439
+ document.querySelectorAll(".dim").forEach(x=>x.classList.remove("dim"));
440
+ Object.values(nodeG).forEach(g=>g.classList.remove("sel"));
441
+ panel.className="empty";panel.querySelector(".detail").innerHTML="";
442
+ }
443
+ function showPanel(id){
444
+ const n=byId[id],ci=catInfo(n.type),rows=[];
445
+ (incident[id]||[]).forEach(i=>{
446
+ const e=DATA.edges[i],out=e.source===id,other=byId[out?e.target:e.source]||{name:(out?e.target:e.source)};
447
+ const model=e.origin==="model";
448
+ rows.push(`<div class="dep"><div class="top">
449
+ <span><span class="arrow">${out?"depends on →":"← used by"}</span> <span class="name">${esc(other.name)}</span></span>
450
+ <span class="tag ${model?"model":"verified"}">${model?"model":"verified"}</span></div>
451
+ <div class="kind">${esc(e.kind)}</div>
452
+ ${e.evidence?`<div class="ev">${esc(e.evidence)}</div>`:""}</div>`);
453
+ });
454
+ panel.className="";
455
+ const hico=DATA.icons[n.type]
456
+ ?`<span class="hico">${DATA.icons[n.type].replace("<svg ",'<svg width="22" height="22" ')}</span>`:"";
457
+ const portalLink = n.id.startsWith("/subscriptions/")
458
+ ? `<a href="https://portal.azure.com/#resource${n.id}" target="_blank" style="display:inline-block;margin:12px 0 4px;background:#0078d4;color:#fff;padding:6px 12px;border-radius:4px;text-decoration:none;font-weight:600;font-size:12.5px;">🔗 Open in Azure Portal</a>`
459
+ : "";
460
+ panel.querySelector(".detail").innerHTML=`
461
+ <h2>${hico}${esc(n.name)}${n.seed?" (seed)":""}</h2>
462
+ <div class="sub">${esc(n.type)} <span class="chip" style="background:${ci.color}">${ci.cat}</span></div>
463
+ ${n.rg?`<div class="kv"><b>group</b><span>${esc(n.rg)}</span></div>`:""}
464
+ ${n.location?`<div class="kv"><b>location</b><span>${esc(n.location)}</span></div>`:""}
465
+ ${n.external?`<div class="kv"><b>status</b><span>external / unverified</span></div>`:""}
466
+ ${n.note?`<div class="kv"><b>note</b><span>${esc(n.note)}</span></div>`:""}
467
+ ${portalLink}
468
+ <div class="deps"><h3>${rows.length} connection${rows.length===1?"":"s"}</h3>${rows.join("")||'<div class="hint">no edges</div>'}</div>`;
469
+ }
470
+ svg.addEventListener("click",e=>{if(e.target===svg||e.target===vp)clearFocus();});
471
+
472
+ // ---- search + toggles + new features ----
473
+ document.getElementById("q").addEventListener("input",e=>{
474
+ const q=e.target.value.trim().toLowerCase();
475
+ DATA.nodes.forEach(n=>{const hit=!q||n.name.toLowerCase().includes(q)||(n.type||"").toLowerCase().includes(q);
476
+ nodeG[n.id]&&nodeG[n.id].classList.toggle("dim",!hit);});
477
+ });
478
+ const rgs = new Set(DATA.nodes.map(n=>n.rg).filter(Boolean));
479
+ rgs.forEach(rg => {
480
+ const opt = document.createElement("option");
481
+ opt.value = opt.textContent = rg;
482
+ document.getElementById("rgFilter").appendChild(opt);
483
+ });
484
+ function applyToggles(){
485
+ const sm=document.getElementById("tModel").checked,se=document.getElementById("tExt").checked;
486
+ const srg = document.getElementById("rgFilter").value;
487
+ DATA.nodes.forEach(n=>{if(!nodeG[n.id])return;
488
+ const hideExt = n.external && !se;
489
+ const hideRg = srg && n.rg && n.rg !== srg;
490
+ nodeG[n.id].classList.toggle("hidden", hideExt || hideRg);
491
+ });
492
+ DATA.edges.forEach((e,i)=>{if(!edgeG[i])return;
493
+ const eh=!se&&((byId[e.source]&&byId[e.source].external)||(byId[e.target]&&byId[e.target].external));
494
+ const er=!srg ? false : ((byId[e.source]&&byId[e.source].rg&&byId[e.source].rg!==srg)||(byId[e.target]&&byId[e.target].rg&&byId[e.target].rg!==srg));
495
+ edgeG[i].classList.toggle("hidden",eh||er||(!sm&&e.origin==="model"));});
496
+ }
497
+ document.getElementById("tModel").addEventListener("change",applyToggles);
498
+ document.getElementById("tExt").addEventListener("change",applyToggles);
499
+ document.getElementById("rgFilter").addEventListener("change",applyToggles);
500
+
501
+ // Dark Mode
502
+ document.getElementById("btnDark").onclick = () => {
503
+ document.body.classList.toggle("dark");
504
+ document.getElementById("btnDark").textContent = document.body.classList.contains("dark") ? "☀️ Light Mode" : "🌙 Dark Mode";
505
+ };
506
+
507
+ // Export logic (PNG & SVG)
508
+ function getExportSVG() {
509
+ const clone = svg.cloneNode(true);
510
+ const bbox = vp.getBBox();
511
+ const w = bbox.width + 100, h = Math.max(bbox.height + 100, 300);
512
+ clone.setAttribute("width", w);
513
+ clone.setAttribute("height", h);
514
+ clone.setAttribute("viewBox", `${bbox.x - 50} ${bbox.y - 50} ${w} ${h}`);
515
+
516
+ // reset transform so it's not exported with current pan/zoom
517
+ clone.querySelector("#vp").setAttribute("transform", "");
518
+
519
+ const style = document.createElement("style");
520
+ style.textContent = `
521
+ .edge path{fill:none;stroke:#8c959f;stroke-width:1.4}
522
+ .edge .elabel{fill:#424a53;font-size:10.5px;font-weight:500;stroke:${document.body.classList.contains('dark') ? '#0d1117' : '#ffffff'};stroke-width:3.5px}
523
+ .edge.model path{stroke:#cf222e;stroke-dasharray:6 5}
524
+ .edge.model .elabel{fill:#cf222e}
525
+ .node .hit{fill:transparent;stroke:none;rx:8}
526
+ .node .ring{fill:none;stroke:none;rx:8}
527
+ .node.seed .ring{stroke:#bf8700;stroke-width:2.5}
528
+ .node .nname{fill:${document.body.classList.contains('dark') ? '#e6edf3' : '#1f2328'};font-weight:600;font-size:12.5px;font-family:sans-serif}
529
+ .node .ntype{fill:#57606a;font-size:10.5px;font-family:sans-serif}
530
+ .node .fbox{fill:#dae8fc;stroke:#6c8ebf;stroke-width:1.4;rx:6}
531
+ .node.external .fbox{fill:#f6f8fa;stroke:#8c959f;stroke-dasharray:5 4}
532
+ .node .fbtext{fill:#1f3b57;font-weight:600;font-size:12px;font-family:sans-serif}
533
+ .node.external .fbtext{fill:#57606a}
534
+ .hidden, .dim {display:none}
535
+ `;
536
+ clone.insertBefore(style, clone.firstChild);
537
+ return { clone, w, h };
538
+ }
539
+
540
+ document.getElementById("btnExportSVG").onclick = () => {
541
+ const { clone } = getExportSVG();
542
+ const svgData = new XMLSerializer().serializeToString(clone);
543
+ const blob = new Blob([svgData], {type: "image/svg+xml;charset=utf-8"});
544
+ const a = document.createElement("a");
545
+ a.href = URL.createObjectURL(blob);
546
+ a.download = "cloudmap_export.svg";
547
+ document.body.appendChild(a);
548
+ a.click();
549
+ document.body.removeChild(a);
550
+ };
551
+
552
+ document.getElementById("btnExportPNG").onclick = () => {
553
+ const { clone, w, h } = getExportSVG();
554
+ const svgData = new XMLSerializer().serializeToString(clone);
555
+ const svg64 = btoa(unescape(encodeURIComponent(svgData)));
556
+
557
+ const canvas = document.createElement("canvas");
558
+ const ctx = canvas.getContext("2d");
559
+ const img = new Image();
560
+ img.onload = function() {
561
+ canvas.width = w * 2; // retina 2x resolution
562
+ canvas.height = h * 2;
563
+ ctx.scale(2, 2);
564
+ ctx.fillStyle = document.body.classList.contains("dark") ? "#0d1117" : "#f3f6f9";
565
+ ctx.fillRect(0, 0, w, h);
566
+ ctx.drawImage(img, 0, 0);
567
+ const a = document.createElement("a");
568
+ a.download = "cloudmap_export.png";
569
+ a.href = canvas.toDataURL("image/png");
570
+ a.click();
571
+ };
572
+ img.src = "data:image/svg+xml;base64," + svg64;
573
+ };
574
+
575
+ fit();
576
+ </script>
577
+ </body>
578
+ </html>
579
+ """
@@ -0,0 +1,52 @@
1
+ """Serialize a graph to a plain JSON inventory (nodes + edges + hop distance)."""
2
+
3
+ import json
4
+
5
+
6
+ def to_json(graph, seed_id, meta=None):
7
+ meta = meta or {}
8
+ external = sum(1 for n in graph.nodes.values() if n.external)
9
+ model_edges = sum(1 for e in graph.edges if e.origin == "model")
10
+ return json.dumps(
11
+ {
12
+ "seed": seed_id,
13
+ "meta": {
14
+ # complete=False means the graph is known to be missing edges/nodes
15
+ # (scan truncated, a live read failed, or a whole class of edge was
16
+ # never looked for) - the artifact says so.
17
+ "complete": not (meta.get("truncated") or meta.get("read_gaps")
18
+ or meta.get("blind_spots")),
19
+ "truncated": bool(meta.get("truncated")),
20
+ "read_gaps": list(meta.get("read_gaps") or []),
21
+ # edges we know we did not go looking for (see cli._enrich_live)
22
+ "blind_spots": list(meta.get("blind_spots") or []),
23
+ "external_unverified": external,
24
+ # how many edges are model-proposed guesses vs verified extractions
25
+ "model_edges": model_edges,
26
+ },
27
+ "nodes": [
28
+ {
29
+ "id": n.id,
30
+ "name": n.name,
31
+ "type": n.type,
32
+ "resourceGroup": n.resource_group,
33
+ "location": n.location,
34
+ "hops": (graph.distances or {}).get(n.id),
35
+ "external": n.external,
36
+ "note": n.note,
37
+ }
38
+ for n in graph.nodes.values()
39
+ ],
40
+ "edges": [
41
+ {
42
+ "source": e.source,
43
+ "target": e.target,
44
+ "kind": e.kind,
45
+ "origin": e.origin, # "extracted" (verified) | "model" (guess)
46
+ "evidence": e.evidence, # the proof behind this edge
47
+ }
48
+ for e in graph.edges
49
+ ],
50
+ },
51
+ indent=2,
52
+ )
@@ -0,0 +1,25 @@
1
+ """Render a graph as a Mermaid flowchart (quick text/preview output)."""
2
+
3
+
4
+ def to_mermaid(graph, seed_id):
5
+ idmap = {nid: f"N{i}" for i, nid in enumerate(graph.nodes)}
6
+ lines = ["graph LR"]
7
+ for nid, mid in idmap.items():
8
+ n = graph.nodes[nid]
9
+ short = n.type.split("/")[-1]
10
+ label = f"{n.name}<br/><small>{short}</small>".replace('"', "'")
11
+ lines.append(f' {mid}["{label}"]')
12
+ for e in graph.edges:
13
+ s, t = idmap.get(e.source), idmap.get(e.target)
14
+ if s and t:
15
+ k = e.kind.replace("|", "/")
16
+ if e.origin == "model":
17
+ lines.append(f' {s} -. "{k} (model)" .-> {t}') # dashed = model guess
18
+ else:
19
+ lines.append(f' {s} -->|{k}| {t}')
20
+ for nid, mid in idmap.items():
21
+ if graph.nodes[nid].external:
22
+ lines.append(f" style {mid} fill:#f5f5f5,stroke:#999,stroke-dasharray:4 3,color:#666")
23
+ if seed_id in idmap:
24
+ lines.append(f" style {idmap[seed_id]} fill:#ffe0b2,stroke:#d79b00,stroke-width:3px")
25
+ return "\n".join(lines) + "\n"