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.
- cloudmap/__init__.py +3 -0
- cloudmap/__main__.py +6 -0
- cloudmap/adapters/__init__.py +91 -0
- cloudmap/ask/__init__.py +111 -0
- cloudmap/ask/intent.py +133 -0
- cloudmap/ask/narration.py +54 -0
- cloudmap/ask/queries.py +296 -0
- cloudmap/cli.py +534 -0
- cloudmap/extract/__init__.py +0 -0
- cloudmap/extract/extractors.py +653 -0
- cloudmap/extract/llm.py +89 -0
- cloudmap/graph.py +208 -0
- cloudmap/ingest/__init__.py +0 -0
- cloudmap/ingest/azure.py +380 -0
- cloudmap/ingest/fixture.py +15 -0
- cloudmap/interactive.py +227 -0
- cloudmap/local_model.py +49 -0
- cloudmap/model.py +43 -0
- cloudmap/render/__init__.py +0 -0
- cloudmap/render/azure_icons.py +72 -0
- cloudmap/render/csv_export.py +47 -0
- cloudmap/render/drawio.py +149 -0
- cloudmap/render/html.py +579 -0
- cloudmap/render/json_out.py +52 -0
- cloudmap/render/mermaid.py +25 -0
- cloudmap/scrub.py +300 -0
- cloudmap-1.0.0.dist-info/METADATA +340 -0
- cloudmap-1.0.0.dist-info/RECORD +31 -0
- cloudmap-1.0.0.dist-info/WHEEL +4 -0
- cloudmap-1.0.0.dist-info/entry_points.txt +2 -0
- cloudmap-1.0.0.dist-info/licenses/LICENSE +21 -0
cloudmap/render/html.py
ADDED
|
@@ -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">−</button>
|
|
226
|
+
<button id="zfit" title="fit">▢</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=>({"&":"&","<":"<",">":">",'"':"""}[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"
|