@thotischner/observability-mcp 1.6.0 → 1.7.0

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.
@@ -930,6 +930,7 @@
930
930
  <button class="nav-btn" data-page="sources" onclick="showPage('sources')"><span class="nav-ico">⊟</span>Sources</button>
931
931
  <button class="nav-btn" data-page="services" onclick="showPage('services')"><span class="nav-ico">⊞</span>Services</button>
932
932
  <button class="nav-btn" data-page="health" onclick="showPage('health')"><span class="nav-ico">✚</span>Health</button>
933
+ <button class="nav-btn" data-page="topology" onclick="showPage('topology')"><span class="nav-ico">◇</span>Topology</button>
933
934
  </div>
934
935
  </div>
935
936
  <div class="rail-grp" data-grp="catalog">
@@ -1139,6 +1140,65 @@
1139
1140
  </div>
1140
1141
  </div>
1141
1142
 
1143
+ <!-- ===== Topology ===== -->
1144
+ <div class="page" id="page-topology">
1145
+ <div class="page-head">
1146
+ <div class="ph-left">
1147
+ <div class="breadcrumb">Console / Observability / <b>Topology</b></div>
1148
+ <h1>Infrastructure Topology</h1>
1149
+ </div>
1150
+ <div class="ph-actions">
1151
+ <button class="btn" onclick="loadTopology()">Refresh</button>
1152
+ </div>
1153
+ </div>
1154
+ <div class="tabs">
1155
+ <button class="tab-btn active" onclick="showTopologyTab('summary')">Summary</button>
1156
+ <button class="tab-btn" onclick="showTopologyTab('blast')">Blast radius</button>
1157
+ <button class="tab-btn" onclick="showTopologyTab('graph')">Graph</button>
1158
+ </div>
1159
+ <div id="topology-tab-summary" class="tab-content active">
1160
+ <div class="card">
1161
+ <div class="card-header"><h2>Sources & counts</h2></div>
1162
+ <div id="topology-summary" class="empty">Loading topology...</div>
1163
+ </div>
1164
+ </div>
1165
+ <div id="topology-tab-blast" class="tab-content">
1166
+ <div class="card">
1167
+ <div class="card-header">
1168
+ <h2>Grouped by host <span class="muted" style="font-weight:400; font-size: var(--fs-sm);">(pivots on the <code>RUNS_ON</code> relation)</span></h2>
1169
+ </div>
1170
+ <div style="padding: 0 16px 8px; display:flex; gap:12px; align-items:center; flex-wrap:wrap;">
1171
+ <label class="muted" style="display:flex; gap:6px; align-items:center; font-size: var(--fs-sm);">
1172
+ Scope filter
1173
+ <select id="topology-scope-filter" style="background:var(--surface); color:var(--text); border:1px solid var(--border); border-radius:6px; padding:4px 8px;">
1174
+ <option value="">All</option>
1175
+ </select>
1176
+ </label>
1177
+ <span class="muted" style="font-size: var(--fs-xs);">Scope = any resource pointed to by an <code>IN_NAMESPACE</code> edge (e.g. k8s namespaces, future: vCenter folders).</span>
1178
+ </div>
1179
+ <div id="topology-by-host" class="empty" style="padding: 0 16px 16px;">Loading...</div>
1180
+ </div>
1181
+ </div>
1182
+ <div id="topology-tab-graph" class="tab-content">
1183
+ <div class="card">
1184
+ <div class="card-header">
1185
+ <h2>Layered graph</h2>
1186
+ <span class="muted" style="font-size: var(--fs-xs);">click a resource to inspect · drag to reposition · wheel to zoom · drag the background to pan</span>
1187
+ </div>
1188
+ <div style="display:grid; grid-template-columns: 1fr 340px; gap: 0; border-top: 1px solid var(--border); height: 660px;">
1189
+ <div id="topology-graph-host" style="position:relative; overflow:hidden; background: var(--surface-2); border-right: 1px solid var(--border);">
1190
+ <svg id="topology-graph-svg" style="position:absolute; inset:0; width:100%; height:100%; cursor: grab;"></svg>
1191
+ <div id="topology-graph-legend" style="position:absolute; bottom:10px; left:10px; font-size: var(--fs-xs); padding:8px 10px; background: var(--surface); border:1px solid var(--border); border-radius: 6px; max-width: 75%;"></div>
1192
+ </div>
1193
+ <aside id="topology-inspector" style="background: var(--surface); padding: 16px; overflow-y: auto; font-size: var(--fs-sm);">
1194
+ <div id="topology-inspector-empty" class="empty" style="margin: 24px 0;">Select a resource on the left to inspect its labels, attributes and neighbours.</div>
1195
+ <div id="topology-inspector-body" style="display:none;"></div>
1196
+ </aside>
1197
+ </div>
1198
+ </div>
1199
+ </div>
1200
+ </div>
1201
+
1142
1202
  <!-- ===== Settings ===== -->
1143
1203
  <div class="page" id="page-settings">
1144
1204
  <div class="page-head">
@@ -1517,6 +1577,7 @@ function showPage(name) {
1517
1577
  if(it) it.classList.add('open');
1518
1578
  if(name==='settings') loadSettingsData();
1519
1579
  if(name==='health') loadHealthData();
1580
+ if(name==='topology') loadTopology();
1520
1581
  if(name==='connectors') loadConnectors();
1521
1582
  if(name==='access'||name==='products'||name==='audit'||name==='entitlement') loadEnterprise();
1522
1583
  }
@@ -2655,6 +2716,768 @@ function renderHealthCards() {
2655
2716
  }).join('');
2656
2717
  }
2657
2718
 
2719
+ // --- Topology ---
2720
+ let topologyData = null;
2721
+ let topologyInterval = null;
2722
+ let topologyScopeFilter = '';
2723
+ let topologyActiveTab = 'summary';
2724
+ let topologyGraphState = null; // memo: last rendered graph state (positions, view)
2725
+ let topologyLastRevHash = ''; // revisions-of-all-sources signature; re-renders only on change
2726
+ let topologySelectedId = null; // currently selected node id (preserved across renders)
2727
+
2728
+ // --- Generic helpers (no kind/relation hardcoding) ---
2729
+
2730
+ // Walk OWNED_BY edges from startId until no outgoing OWNED_BY remains.
2731
+ // Returns the terminal owner id (or startId if it has no owner). Universal:
2732
+ // k8s pod→rs→deployment, vCenter vm→resource-pool→folder, AWS pod→ASG, etc.
2733
+ function ownershipRoot(startId, ownedByMap){
2734
+ let cur = startId;
2735
+ for (let i = 0; i < 16; i++) { // hard cap defends against cycles
2736
+ const next = ownedByMap.get(cur);
2737
+ if (!next || next === cur) return cur;
2738
+ cur = next;
2739
+ }
2740
+ return cur;
2741
+ }
2742
+
2743
+ // Index edges once per render so views are O(N).
2744
+ function indexTopology(d){
2745
+ const byId = new Map();
2746
+ for (const r of d.resources) byId.set(r.id, r);
2747
+ const runsOn = new Map(); // from → to (assume single host per child)
2748
+ const ownedBy = new Map(); // from → to (single owner walk)
2749
+ const inScope = new Map(); // from → to (single IN_NAMESPACE-style scope per resource)
2750
+ const scopeKindCount = {}; // kind of every IN_NAMESPACE target → count of incoming
2751
+ for (const e of d.edges){
2752
+ if (e.relation === 'RUNS_ON') runsOn.set(e.from, e.to);
2753
+ else if (e.relation === 'OWNED_BY') {
2754
+ // keep only the first OWNED_BY out-edge per source (most connectors emit
2755
+ // one); the walker handles chains via the map.
2756
+ if (!ownedBy.has(e.from)) ownedBy.set(e.from, e.to);
2757
+ } else if (e.relation === 'IN_NAMESPACE') {
2758
+ if (!inScope.has(e.from)) inScope.set(e.from, e.to);
2759
+ const target = byId.get(e.to);
2760
+ if (target) scopeKindCount[target.kind] = (scopeKindCount[target.kind]||0)+1;
2761
+ }
2762
+ }
2763
+ return { byId, runsOn, ownedBy, inScope, scopeKindCount };
2764
+ }
2765
+
2766
+ async function loadTopology(){
2767
+ try {
2768
+ const r = await fetch('/api/topology');
2769
+ if (!r.ok) throw new Error('http '+r.status);
2770
+ const fresh = await r.json();
2771
+ // Only re-render when topology actually changed. Otherwise the
2772
+ // 5-second poll would rebuild the SVG and wipe the user's selection.
2773
+ const revHash = (fresh.sources || []).map(s => `${s.source}:${s.revision}`).join('|') +
2774
+ `#r=${fresh.resources.length}#e=${fresh.edges.length}`;
2775
+ const isFirst = topologyData == null;
2776
+ topologyData = fresh;
2777
+ if (isFirst || revHash !== topologyLastRevHash) {
2778
+ topologyLastRevHash = revHash;
2779
+ renderTopology();
2780
+ }
2781
+ } catch(e) {
2782
+ topologyData = null;
2783
+ topologyLastRevHash = '';
2784
+ document.getElementById('topology-summary').innerHTML =
2785
+ '<div class="empty">No topology data available. Add a topology-capable source (e.g. <code>kubernetes</code>) under Sources.</div>';
2786
+ const bh = document.getElementById('topology-by-host'); if (bh) bh.innerHTML = '';
2787
+ }
2788
+ if(!topologyInterval) topologyInterval = setInterval(()=>{
2789
+ if(document.getElementById('page-topology').classList.contains('active')) loadTopology();
2790
+ }, 5000);
2791
+ }
2792
+
2793
+ function showTopologyTab(name){
2794
+ topologyActiveTab = name;
2795
+ const page = document.getElementById('page-topology');
2796
+ page.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active'));
2797
+ page.querySelectorAll('.tab-content').forEach(c=>c.classList.remove('active'));
2798
+ const btn = Array.from(page.querySelectorAll('.tab-btn')).find(b=>b.textContent.trim().toLowerCase().includes(name)||(name==='blast'&&b.textContent.includes('Blast'))||(name==='graph'&&b.textContent==='Graph')||(name==='summary'&&b.textContent==='Summary'));
2799
+ if (btn) btn.classList.add('active');
2800
+ document.getElementById('topology-tab-'+name).classList.add('active');
2801
+ if (name === 'graph') renderTopologyGraph(); // graph rendering is lazy
2802
+ }
2803
+
2804
+ function renderTopology(){
2805
+ const d = topologyData || { sources: [], resources: [], edges: [] };
2806
+ const idx = indexTopology(d);
2807
+
2808
+ // --- Sync scope filter (any IN_NAMESPACE target — generic) ---
2809
+ // Build the option list from actual edge targets, not from a hardcoded kind.
2810
+ const scopeIds = new Set(idx.inScope.values());
2811
+ const scopeRes = Array.from(scopeIds).map(id=>idx.byId.get(id)).filter(Boolean)
2812
+ .sort((a,b)=>a.name.localeCompare(b.name));
2813
+ const sel = document.getElementById('topology-scope-filter');
2814
+ if (sel) {
2815
+ const prev = topologyScopeFilter;
2816
+ sel.innerHTML = '<option value="">All</option>' +
2817
+ scopeRes.map(r=>`<option value="${esc(r.id)}"${prev===r.id?' selected':''}>${esc(r.name)} <span>(${esc(r.kind)})</span></option>`).join('');
2818
+ sel.onchange = (e)=>{ topologyScopeFilter = e.target.value; renderTopology(); };
2819
+ }
2820
+
2821
+ // --- Summary tab ---
2822
+ const summary = document.getElementById('topology-summary');
2823
+ if (d.sources.length === 0) {
2824
+ summary.innerHTML = '<div class="empty">No topology-capable sources connected. Add a topology source (e.g. <b>kubernetes</b>) under Sources.</div>';
2825
+ const bh = document.getElementById('topology-by-host'); if (bh) bh.innerHTML = '';
2826
+ renderTopologyGraph(); // shows empty-state legend too
2827
+ return;
2828
+ }
2829
+ const kindCounts = {}, relCounts = {};
2830
+ for (const r of d.resources) kindCounts[r.kind] = (kindCounts[r.kind]||0)+1;
2831
+ for (const e of d.edges) relCounts[e.relation] = (relCounts[e.relation]||0)+1;
2832
+ const srcBadges = d.sources.map(s=>`<span class="badge">${esc(s.source)} <span class="muted">(${esc(s.type)}, rev ${s.revision})</span></span>`).join(' ');
2833
+ const kindRow = Object.entries(kindCounts).sort((a,b)=>b[1]-a[1])
2834
+ .map(([k,v])=>`<div class="hc-metric"><div class="label">${esc(k)}</div><div class="val">${v}</div></div>`).join('');
2835
+ const relRow = Object.entries(relCounts).sort((a,b)=>b[1]-a[1])
2836
+ .map(([k,v])=>`<div class="hc-metric"><div class="label">${esc(k)}</div><div class="val">${v}</div></div>`).join('');
2837
+ summary.innerHTML = `
2838
+ <div style="display:flex; flex-direction:column; gap:12px;">
2839
+ <div><b>Sources:</b> ${srcBadges}</div>
2840
+ <div>
2841
+ <div class="muted" style="margin-bottom:6px;">Resources by kind</div>
2842
+ <div class="hc-metrics">${kindRow}</div>
2843
+ </div>
2844
+ <div>
2845
+ <div class="muted" style="margin-bottom:6px;">Edges by relation</div>
2846
+ <div class="hc-metrics">${relRow}</div>
2847
+ </div>
2848
+ </div>`;
2849
+
2850
+ // --- Blast radius tab (relation-driven on RUNS_ON) ---
2851
+ renderBlastRadius(d, idx);
2852
+ // --- Graph tab (only re-render if it's active to avoid wasted simulation) ---
2853
+ if (topologyActiveTab === 'graph') renderTopologyGraph();
2854
+ }
2855
+
2856
+ function renderBlastRadius(d, idx){
2857
+ const target = document.getElementById('topology-by-host');
2858
+ if (!target) return;
2859
+
2860
+ // Hosts = anything that is the TO of at least one RUNS_ON edge. Generic:
2861
+ // k8s nodes, vCenter hypervisors, NetBox switches, libvirt hosts, …
2862
+ const hostIds = new Set(idx.runsOn.values());
2863
+ if (hostIds.size === 0){
2864
+ target.innerHTML = '<div class="empty">No <code>RUNS_ON</code> edges in the graph yet — blast-radius view appears once topology connectors report which resources run on which hosts.</div>';
2865
+ return;
2866
+ }
2867
+
2868
+ // Optional scope filter applied to the children (not the hosts).
2869
+ const scope = topologyScopeFilter; // resource id, or ''
2870
+
2871
+ const cards = Array.from(hostIds).map(hostId=>{
2872
+ const host = idx.byId.get(hostId);
2873
+ const hostName = host ? host.name : hostId;
2874
+ const hostKind = host ? host.kind : '?';
2875
+ // children that RUN_ON this host
2876
+ let children = [];
2877
+ for (const [fromId, toId] of idx.runsOn.entries()){
2878
+ if (toId !== hostId) continue;
2879
+ if (scope && idx.inScope.get(fromId) !== scope) continue;
2880
+ const c = idx.byId.get(fromId);
2881
+ if (c) children.push(c);
2882
+ }
2883
+ // Group children by their ownership root (terminal OWNED_BY target).
2884
+ const groups = new Map();
2885
+ for (const c of children){
2886
+ const rootId = ownershipRoot(c.id, idx.ownedBy);
2887
+ const arr = groups.get(rootId) || [];
2888
+ arr.push(c);
2889
+ groups.set(rootId, arr);
2890
+ }
2891
+ const rootIds = Array.from(groups.keys()).sort();
2892
+ const sharedNote = rootIds.length > 1
2893
+ ? `<div class="hc-correlation">${rootIds.length} ownership roots share this host — blast radius if it fails</div>` : '';
2894
+ const body = rootIds.length === 0
2895
+ ? `<div class="empty">No resources on this host${scope?` matching the scope filter`:''}.</div>`
2896
+ : rootIds.map(rid=>{
2897
+ const rootRes = idx.byId.get(rid);
2898
+ const rootLabel = rootRes
2899
+ ? `${esc(rootRes.name)} <span class="muted">(${esc(rootRes.kind)})</span>`
2900
+ : `<span class="muted">${esc(rid)}</span>`;
2901
+ const items = groups.get(rid).map(c=>{
2902
+ const attrs = c.attributes || {};
2903
+ // Show up to 2 generic attribute hints — no specific keys assumed.
2904
+ const hints = Object.entries(attrs).filter(([k])=>k!=='uid').slice(0,2)
2905
+ .map(([k,v])=>`${esc(k)}=${esc(String(v))}`).join(' · ');
2906
+ return `<li>
2907
+ <span style="font-family:monospace; font-size:12px;">${esc(c.name)}</span>
2908
+ <span class="muted" style="font-size: 11px;">${esc(c.kind)}${hints?' · '+hints:''}</span>
2909
+ </li>`;
2910
+ }).join('');
2911
+ return `<div style="margin: 8px 0 6px;">
2912
+ <div style="font-weight:600; color:var(--text);">${rootLabel} <span class="muted">— ${groups.get(rid).length} resource${groups.get(rid).length===1?'':'s'}</span></div>
2913
+ <ul style="margin: 4px 0 0 18px; padding:0;">${items}</ul>
2914
+ </div>`;
2915
+ }).join('');
2916
+ return `<div class="card" style="margin-bottom: 12px;">
2917
+ <div class="card-header"><h2 style="font-size:14px;">⬡ ${esc(hostName)} <span class="muted" style="font-weight:400; font-size:12px;">(${esc(hostKind)})</span></h2>
2918
+ <span class="muted" style="font-size:12px;">${children.length} child${children.length===1?'':'ren'}</span>
2919
+ </div>
2920
+ <div style="padding: 0 16px 12px;">
2921
+ ${sharedNote}
2922
+ ${body}
2923
+ </div>
2924
+ </div>`;
2925
+ }).join('');
2926
+ target.innerHTML = cards;
2927
+ }
2928
+
2929
+ // --- Layered topology graph (hand-rolled Sugiyama-style, no external deps) ---
2930
+ // Hosts (incoming RUNS_ON targets) anchor the top tier; ownership chains
2931
+ // drop down towards the workload tier at the bottom. IN_NAMESPACE targets
2932
+ // are drawn as tinted background bands rather than edges — drawing them
2933
+ // as edges turns into a star pattern and dominates the view.
2934
+ //
2935
+ // Framework-free to keep the airgapped build path clean
2936
+ // (docs/airgapped-deployment.md). Layout uses one-pass barycenter sorting
2937
+ // for crossing reduction — enough for graphs of a few hundred nodes; for
2938
+ // larger graphs the path forward is full Sugiyama with crossing-min.
2939
+
2940
+ const TOPO_KIND_PALETTE = [
2941
+ '#58a6ff','#3fb950','#f0883e','#a371f7','#f85149',
2942
+ '#79c0ff','#56d364','#ffa657','#d2a8ff','#ff7b72',
2943
+ ];
2944
+ const TOPO_REL_STYLE = {
2945
+ RUNS_ON: { stroke: 'var(--text-muted)', dash: '', width: 1.5 },
2946
+ OWNED_BY: { stroke: 'var(--accent, #58a6ff)', dash: '4 4', width: 1.3 },
2947
+ };
2948
+ const TOPO_REL_DEFAULT = { stroke: 'var(--text-muted)', dash: '', width: 1.2 };
2949
+
2950
+ function topoKindColor(kind){
2951
+ let h = 0; for (let i = 0; i < kind.length; i++) h = (h*31 + kind.charCodeAt(i)) | 0;
2952
+ return TOPO_KIND_PALETTE[Math.abs(h) % TOPO_KIND_PALETTE.length];
2953
+ }
2954
+ function topoRelStyle(rel){ return TOPO_REL_STYLE[rel] || TOPO_REL_DEFAULT; }
2955
+
2956
+ function renderTopologyGraph(){
2957
+ const svg = document.getElementById('topology-graph-svg');
2958
+ const host = document.getElementById('topology-graph-host');
2959
+ const legend = document.getElementById('topology-graph-legend');
2960
+ if (!svg || !host) return;
2961
+ const d = topologyData || { resources: [], edges: [] };
2962
+
2963
+ // Reset
2964
+ while (svg.firstChild) svg.removeChild(svg.firstChild);
2965
+ if (d.resources.length === 0){
2966
+ legend.innerHTML = '<span class="muted">No resources to plot.</span>';
2967
+ return;
2968
+ }
2969
+
2970
+ const idx = indexTopology(d);
2971
+
2972
+ // --- Classify resources --------------------------------------------------
2973
+ // A "scope" is a resource that is targeted by IN_NAMESPACE AND has no
2974
+ // outgoing edges of its own (i.e. a container, not a workload). Drawn as
2975
+ // tinted background bands behind their members, not as graph nodes.
2976
+ // Works generically: k8s namespace, vCenter folder, AWS account, …
2977
+ const scopeTargets = new Set(idx.inScope.values());
2978
+ const outgoingByFrom = {};
2979
+ for (const e of d.edges){
2980
+ (outgoingByFrom[e.from] = outgoingByFrom[e.from] || []).push(e);
2981
+ }
2982
+ const pureScope = new Set();
2983
+ for (const id of scopeTargets){
2984
+ if ((outgoingByFrom[id] || []).length === 0 && !idx.runsOn.has(id)) pureScope.add(id);
2985
+ }
2986
+
2987
+ const drawables = d.resources.filter(r=>!pureScope.has(r.id));
2988
+
2989
+ // --- Tier assignment -----------------------------------------------------
2990
+ // Hosts (incoming RUNS_ON) → tier 0 (top).
2991
+ // Other resources → tier = 1 + length of longest OWNED_BY chain TO that
2992
+ // resource (i.e. roots like Deployment sit close to top, deepest leaves
2993
+ // like Pods sit at the bottom). Generic: works for any OWNED_BY chain.
2994
+ const incomingRunsOn = new Set(idx.runsOn.values());
2995
+ // Build outgoingOwnedBy (already in idx as ownedBy: from → to).
2996
+ // Compute longest path from each node BACKWARDS via OWNED_BY (i.e. how
2997
+ // deep it sits below the ownership root). Equivalent: longest chain of
2998
+ // OWNED_BY out-edges starting at this node.
2999
+ const ownDepth = new Map();
3000
+ function computeOwnDepth(id, seen){
3001
+ if (ownDepth.has(id)) return ownDepth.get(id);
3002
+ if (seen.has(id)) return 0; // cycle guard
3003
+ seen.add(id);
3004
+ const next = idx.ownedBy.get(id);
3005
+ const dep = next ? 1 + computeOwnDepth(next, new Set(seen)) : 0;
3006
+ ownDepth.set(id, dep);
3007
+ return dep;
3008
+ }
3009
+ for (const r of drawables) computeOwnDepth(r.id, new Set());
3010
+
3011
+ // Maximum chain depth across non-host resources informs how many tiers
3012
+ // we draw below the host row.
3013
+ let maxChain = 0;
3014
+ for (const r of drawables){
3015
+ if (incomingRunsOn.has(r.id)) continue;
3016
+ const dd = ownDepth.get(r.id) || 0;
3017
+ if (dd > maxChain) maxChain = dd;
3018
+ }
3019
+
3020
+ function tierOf(r){
3021
+ if (incomingRunsOn.has(r.id)) return 0; // top: hosts
3022
+ // Below hosts: tier = (maxChain - ownDepth) + 1
3023
+ // So an ownership root (depth=0) sits at tier 1; deepest leaves at tier maxChain+1.
3024
+ return (maxChain - (ownDepth.get(r.id) || 0)) + 1;
3025
+ }
3026
+
3027
+ // Bucket resources per tier (deterministic order: by name, then id).
3028
+ const tierMap = new Map();
3029
+ for (const r of drawables){
3030
+ const t = tierOf(r);
3031
+ const arr = tierMap.get(t) || [];
3032
+ arr.push(r);
3033
+ tierMap.set(t, arr);
3034
+ }
3035
+ const tierIndices = Array.from(tierMap.keys()).sort((a,b)=>a-b);
3036
+
3037
+ // --- Barycenter ordering (one-pass) to reduce edge crossings -------------
3038
+ // Place tier 0 alphabetically. For each subsequent tier, order nodes by
3039
+ // the mean x-position of the parents they connect to (RUNS_ON or
3040
+ // OWNED_BY target) in the tier above. Simple but effective for small
3041
+ // graphs and matches what dagre / Sugiyama do as a base step.
3042
+ function parentsOf(r){
3043
+ const parents = [];
3044
+ if (idx.ownedBy.get(r.id)) parents.push(idx.ownedBy.get(r.id));
3045
+ if (idx.runsOn.get(r.id)) parents.push(idx.runsOn.get(r.id));
3046
+ return parents;
3047
+ }
3048
+ // Layout dimensions
3049
+ const W = host.clientWidth || 1000, H = host.clientHeight || 660;
3050
+ const TOP_MARGIN = 36, BOTTOM_MARGIN = 36;
3051
+ const TIER_LABEL_W = 110; // left gutter reserved for tier labels
3052
+ const SIDE_MARGIN = TIER_LABEL_W + 28;
3053
+ const RIGHT_MARGIN = 32;
3054
+ const totalTiers = tierIndices.length;
3055
+ const tierHeight = (H - TOP_MARGIN - BOTTOM_MARGIN) / Math.max(1, totalTiers);
3056
+ const positions = new Map(); // id → {x,y}
3057
+
3058
+ // Initial ordering: alphabetical within tier
3059
+ for (const t of tierIndices) tierMap.get(t).sort((a,b)=>a.name.localeCompare(b.name));
3060
+
3061
+ for (let i = 0; i < tierIndices.length; i++){
3062
+ const t = tierIndices[i];
3063
+ const row = tierMap.get(t);
3064
+ if (i > 0){
3065
+ // Compute barycenter x from already-placed parents.
3066
+ const bary = new Map();
3067
+ for (const r of row){
3068
+ const ps = parentsOf(r).map(pid => positions.get(pid)?.x).filter(x=>x!=null);
3069
+ bary.set(r.id, ps.length ? ps.reduce((a,b)=>a+b,0) / ps.length : Number.POSITIVE_INFINITY);
3070
+ }
3071
+ row.sort((a,b)=>{
3072
+ const ba = bary.get(a.id), bb = bary.get(b.id);
3073
+ if (ba === bb) return a.name.localeCompare(b.name);
3074
+ return ba - bb;
3075
+ });
3076
+ }
3077
+ const usableW = W - SIDE_MARGIN - RIGHT_MARGIN;
3078
+ const step = row.length > 1 ? usableW / (row.length - 1) : 0;
3079
+ const y = TOP_MARGIN + i * tierHeight + tierHeight/2;
3080
+ for (let j = 0; j < row.length; j++){
3081
+ const x = row.length === 1 ? SIDE_MARGIN + usableW/2 : SIDE_MARGIN + j * step;
3082
+ positions.set(row[j].id, { x, y });
3083
+ }
3084
+ }
3085
+
3086
+ // Pick a label per tier: the most frequent kind. Falls back to "tier N".
3087
+ function tierLabelFor(rows){
3088
+ if (!rows || rows.length === 0) return '';
3089
+ const counts = {};
3090
+ for (const r of rows) counts[r.kind] = (counts[r.kind] || 0) + 1;
3091
+ return Object.entries(counts).sort((a,b)=>b[1]-a[1])[0][0];
3092
+ }
3093
+
3094
+ // --- Scope background bands ---------------------------------------------
3095
+ // For each pure-scope (e.g. namespace), find members and draw a tinted
3096
+ // rectangle behind them. The bands span the union of members' bounding
3097
+ // boxes, so they visually cluster siblings without occluding anything.
3098
+ const scopeColor = (id)=>{
3099
+ const r = idx.byId.get(id);
3100
+ const base = topoKindColor(r ? r.kind : 'scope');
3101
+ // soft alpha + slightly lighter
3102
+ return base.replace(/^#/, '#') + '22'; // append alpha (works because we use hex)
3103
+ };
3104
+ const scopeBands = [];
3105
+ for (const scopeId of pureScope){
3106
+ const members = drawables.filter(r => idx.inScope.get(r.id) === scopeId).map(r => positions.get(r.id)).filter(Boolean);
3107
+ if (members.length === 0) continue;
3108
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
3109
+ for (const p of members){
3110
+ if (p.x < minX) minX = p.x; if (p.x > maxX) maxX = p.x;
3111
+ if (p.y < minY) minY = p.y; if (p.y > maxY) maxY = p.y;
3112
+ }
3113
+ const PADX = 28, PADY = 18;
3114
+ scopeBands.push({
3115
+ id: scopeId,
3116
+ ref: idx.byId.get(scopeId),
3117
+ x: minX - PADX, y: minY - PADY,
3118
+ w: (maxX - minX) + 2*PADX,
3119
+ h: (maxY - minY) + 2*PADY,
3120
+ fill: scopeColor(scopeId),
3121
+ });
3122
+ }
3123
+
3124
+ // --- Legend (auto-derived) ----------------------------------------------
3125
+ const kinds = Array.from(new Set(drawables.map(r=>r.kind))).sort();
3126
+ const rels = Array.from(new Set(d.edges.map(e=>e.relation).filter(r=>r!=='IN_NAMESPACE'))).sort();
3127
+ legend.innerHTML =
3128
+ '<div style="display:flex; gap:14px; flex-wrap:wrap; align-items:center;">' +
3129
+ '<div><b>Kinds:</b> ' + kinds.map(k=>
3130
+ `<span style="display:inline-flex; gap:4px; align-items:center; margin-right:8px;"><span style="display:inline-block; width:8px; height:8px; border-radius:50%; background:${topoKindColor(k)};"></span>${esc(k)}</span>`
3131
+ ).join('') + '</div>' +
3132
+ '<div><b>Edges:</b> ' + rels.map(r=>{
3133
+ const s = topoRelStyle(r);
3134
+ return `<span style="display:inline-flex; gap:4px; align-items:center; margin-right:8px;"><span style="display:inline-block; width:18px; height:0; border-bottom: ${s.width}px ${s.dash?'dashed':'solid'} ${s.stroke};"></span>${esc(r)}</span>`;
3135
+ }).join('') + '</div>' +
3136
+ (pureScope.size > 0 ? '<div class="muted"><b>Scope</b> (e.g. namespaces) shown as tinted backgrounds, not edges.</div>' : '') +
3137
+ '</div>';
3138
+
3139
+ // --- Build SVG -----------------------------------------------------------
3140
+ svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
3141
+ const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
3142
+ g.setAttribute('id', 'topo-g');
3143
+ let view = { tx: 0, ty: 0, scale: 1 };
3144
+ function applyView(){ g.setAttribute('transform', `translate(${view.tx} ${view.ty}) scale(${view.scale})`); }
3145
+ applyView();
3146
+ svg.appendChild(g);
3147
+
3148
+ // Alternating tier background bands + tier labels in the left gutter.
3149
+ for (let i = 0; i < tierIndices.length; i++){
3150
+ const yTop = TOP_MARGIN + i * tierHeight;
3151
+ if (i % 2 === 1){
3152
+ const band = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
3153
+ band.setAttribute('x', '0'); band.setAttribute('y', String(yTop));
3154
+ band.setAttribute('width', String(W)); band.setAttribute('height', String(tierHeight));
3155
+ band.setAttribute('fill', 'var(--text)');
3156
+ band.setAttribute('fill-opacity', '0.025');
3157
+ g.appendChild(band);
3158
+ }
3159
+ // tier separator line
3160
+ if (i > 0){
3161
+ const sep = document.createElementNS('http://www.w3.org/2000/svg', 'line');
3162
+ sep.setAttribute('x1', '0'); sep.setAttribute('y1', String(yTop));
3163
+ sep.setAttribute('x2', String(W)); sep.setAttribute('y2', String(yTop));
3164
+ sep.setAttribute('stroke', 'var(--border)');
3165
+ sep.setAttribute('stroke-opacity', '0.5');
3166
+ sep.setAttribute('stroke-dasharray', '2 4');
3167
+ g.appendChild(sep);
3168
+ }
3169
+ const lbl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
3170
+ lbl.setAttribute('x', '16'); lbl.setAttribute('y', String(yTop + tierHeight/2 + 3));
3171
+ lbl.setAttribute('font-size', '11');
3172
+ lbl.setAttribute('font-weight', '600');
3173
+ lbl.setAttribute('fill', 'var(--text-muted)');
3174
+ lbl.setAttribute('letter-spacing', '0.08em');
3175
+ lbl.textContent = tierLabelFor(tierMap.get(tierIndices[i])).toUpperCase();
3176
+ g.appendChild(lbl);
3177
+ }
3178
+
3179
+ // Scope bands (drawn over tier bands so members are clearly grouped)
3180
+ scopeBands.sort((a,b)=>(b.w*b.h)-(a.w*a.h));
3181
+ for (const b of scopeBands){
3182
+ const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
3183
+ rect.setAttribute('x', b.x); rect.setAttribute('y', b.y);
3184
+ rect.setAttribute('width', b.w); rect.setAttribute('height', b.h);
3185
+ rect.setAttribute('rx', '14'); rect.setAttribute('ry', '14');
3186
+ rect.setAttribute('fill', b.fill);
3187
+ rect.setAttribute('stroke', topoKindColor(b.ref ? b.ref.kind : 'scope'));
3188
+ rect.setAttribute('stroke-opacity', '0.3');
3189
+ rect.setAttribute('stroke-dasharray', '2 4');
3190
+ g.appendChild(rect);
3191
+ const lbl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
3192
+ lbl.setAttribute('x', b.x + 12); lbl.setAttribute('y', b.y + 16);
3193
+ lbl.setAttribute('font-size', '10');
3194
+ lbl.setAttribute('fill', 'var(--text-muted)');
3195
+ lbl.textContent = `${b.ref ? b.ref.name : b.id} (${b.ref ? b.ref.kind : 'scope'})`;
3196
+ g.appendChild(lbl);
3197
+ }
3198
+
3199
+ // Edges as Bezier paths (smooth vertical flow between tiers).
3200
+ // Skip IN_NAMESPACE since scopes are drawn as bands.
3201
+ const drawEdges = d.edges.filter(e =>
3202
+ e.relation !== 'IN_NAMESPACE' && positions.has(e.from) && positions.has(e.to)
3203
+ );
3204
+ function bezierPath(a, b){
3205
+ // Vertical-bias S-curve; control points pulled toward each endpoint's y
3206
+ // so the line leaves and enters its node vertically. Reads as "flow
3207
+ // downward through tiers" without zig-zags.
3208
+ const cy = (a.y + b.y) / 2;
3209
+ return `M ${a.x} ${a.y} C ${a.x} ${cy}, ${b.x} ${cy}, ${b.x} ${b.y}`;
3210
+ }
3211
+ for (const e of drawEdges){
3212
+ const a = positions.get(e.from), b = positions.get(e.to);
3213
+ const s = topoRelStyle(e.relation);
3214
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
3215
+ path.setAttribute('d', bezierPath(a, b));
3216
+ path.setAttribute('fill', 'none');
3217
+ path.setAttribute('stroke', s.stroke);
3218
+ path.setAttribute('stroke-width', String(s.width));
3219
+ if (s.dash) path.setAttribute('stroke-dasharray', s.dash);
3220
+ path.setAttribute('opacity', '0.85');
3221
+ path.dataset.from = e.from; path.dataset.to = e.to;
3222
+ g.appendChild(path);
3223
+ }
3224
+
3225
+ // Nodes
3226
+ // Compute a per-tier label budget so long names (k8s pod hashes etc.)
3227
+ // don't bleed into each other. Each node "owns" the slot width between
3228
+ // its neighbours in the same row.
3229
+ const labelBudget = new Map(); // id → max chars
3230
+ for (const t of tierIndices){
3231
+ const row = tierMap.get(t);
3232
+ const usableW = W - SIDE_MARGIN - RIGHT_MARGIN;
3233
+ const slot = row.length > 1 ? usableW / row.length : usableW;
3234
+ // ~6.5 px per character at font-size 10
3235
+ const maxChars = Math.max(6, Math.floor(slot / 6.5) - 2);
3236
+ for (const r of row) labelBudget.set(r.id, maxChars);
3237
+ }
3238
+ function truncLabel(name, max){
3239
+ if (name.length <= max) return name;
3240
+ // For k8s-style names like "checkout-7f89d-mdblh" the suffix carries
3241
+ // the identifying entropy; keep the tail.
3242
+ return '…' + name.slice(-(max - 1));
3243
+ }
3244
+
3245
+ const nodeEls = new Map();
3246
+ for (const r of drawables){
3247
+ const p = positions.get(r.id); if (!p) continue;
3248
+ const grp = document.createElementNS('http://www.w3.org/2000/svg', 'g');
3249
+ grp.setAttribute('transform', `translate(${p.x} ${p.y})`);
3250
+ grp.style.cursor = 'grab';
3251
+ grp.dataset.id = r.id;
3252
+ const c = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
3253
+ // Hosts a touch bigger to read as anchors of the layout.
3254
+ const radius = incomingRunsOn.has(r.id) ? 9 : 6.5;
3255
+ c.setAttribute('r', String(radius));
3256
+ c.setAttribute('fill', topoKindColor(r.kind));
3257
+ c.setAttribute('stroke', 'rgba(0,0,0,0.45)');
3258
+ c.setAttribute('stroke-width', '0.8');
3259
+ grp.appendChild(c);
3260
+ // Label centered BELOW the node. A subtle text-stroke in the surface
3261
+ // color acts as a halo so labels stay readable when they cross a
3262
+ // scope band or an edge.
3263
+ const txt = document.createElementNS('http://www.w3.org/2000/svg', 'text');
3264
+ txt.setAttribute('x', '0');
3265
+ txt.setAttribute('y', String(radius + 12));
3266
+ txt.setAttribute('font-size', '10');
3267
+ txt.setAttribute('text-anchor', 'middle');
3268
+ txt.setAttribute('fill', 'var(--text)');
3269
+ txt.setAttribute('stroke', 'var(--surface-2)');
3270
+ txt.setAttribute('stroke-width', '3');
3271
+ txt.setAttribute('paint-order', 'stroke');
3272
+ txt.setAttribute('style', 'pointer-events: none;');
3273
+ txt.textContent = truncLabel(r.name, labelBudget.get(r.id) || 16);
3274
+ // SVG <title> = native browser tooltip for the full name on hover.
3275
+ const tip = document.createElementNS('http://www.w3.org/2000/svg', 'title');
3276
+ tip.textContent = r.name;
3277
+ grp.appendChild(tip);
3278
+ grp.appendChild(txt);
3279
+ g.appendChild(grp);
3280
+ nodeEls.set(r.id, { grp, circle: c, radius });
3281
+ }
3282
+
3283
+ // --- Interactions (drag a node, pan background, wheel zoom, click info) --
3284
+ let dragId = null, dragOffset = null;
3285
+ let panning = false, panStart = null;
3286
+
3287
+ function clientToWorld(cx, cy){
3288
+ const rect = svg.getBoundingClientRect();
3289
+ return { x: (cx - rect.left - view.tx) / view.scale, y: (cy - rect.top - view.ty) / view.scale };
3290
+ }
3291
+ function repaintEdgesFor(id){
3292
+ g.querySelectorAll('path[data-from]').forEach(pth=>{
3293
+ if (pth.dataset.from === id || pth.dataset.to === id){
3294
+ const a = positions.get(pth.dataset.from);
3295
+ const b = positions.get(pth.dataset.to);
3296
+ if (a && b) pth.setAttribute('d', bezierPath(a, b));
3297
+ }
3298
+ });
3299
+ }
3300
+
3301
+ svg.onwheel = (ev)=>{
3302
+ ev.preventDefault();
3303
+ const k = ev.deltaY < 0 ? 1.12 : 1/1.12;
3304
+ const rect = svg.getBoundingClientRect();
3305
+ const mx = ev.clientX - rect.left, my = ev.clientY - rect.top;
3306
+ view.tx = mx - (mx - view.tx) * k;
3307
+ view.ty = my - (my - view.ty) * k;
3308
+ view.scale *= k;
3309
+ applyView();
3310
+ };
3311
+ svg.onmousedown = (ev)=>{
3312
+ const el = ev.target.closest && ev.target.closest('g[data-id]');
3313
+ if (el){
3314
+ dragId = el.dataset.id;
3315
+ const p = positions.get(dragId);
3316
+ const w2 = clientToWorld(ev.clientX, ev.clientY);
3317
+ dragOffset = { dx: p.x - w2.x, dy: p.y - w2.y };
3318
+ svg.style.cursor = 'grabbing';
3319
+ } else {
3320
+ panning = true;
3321
+ panStart = { x: ev.clientX - view.tx, y: ev.clientY - view.ty };
3322
+ svg.style.cursor = 'grabbing';
3323
+ }
3324
+ };
3325
+ function onMove(ev){
3326
+ if (dragId){
3327
+ const w2 = clientToWorld(ev.clientX, ev.clientY);
3328
+ const p = { x: w2.x + dragOffset.dx, y: w2.y + dragOffset.dy };
3329
+ positions.set(dragId, p);
3330
+ const el = nodeEls.get(dragId);
3331
+ if (el) el.grp.setAttribute('transform', `translate(${p.x} ${p.y})`);
3332
+ repaintEdgesFor(dragId);
3333
+ } else if (panning){
3334
+ view.tx = ev.clientX - panStart.x;
3335
+ view.ty = ev.clientY - panStart.y;
3336
+ applyView();
3337
+ }
3338
+ }
3339
+ function onUp(){
3340
+ if (dragId){ dragId = null; svg.style.cursor = 'grab'; }
3341
+ if (panning){ panning = false; svg.style.cursor = 'grab'; }
3342
+ }
3343
+ // Re-attach (these handlers are global by design — pointer can leave svg)
3344
+ window.removeEventListener('mousemove', window.__topoMove);
3345
+ window.removeEventListener('mouseup', window.__topoUp);
3346
+ window.__topoMove = onMove; window.__topoUp = onUp;
3347
+ window.addEventListener('mousemove', window.__topoMove);
3348
+ window.addEventListener('mouseup', window.__topoUp);
3349
+
3350
+ // Click: highlight 1-hop neighbours + populate the side inspector.
3351
+ function clearHighlight(){
3352
+ g.querySelectorAll('path[data-from]').forEach(p=>p.setAttribute('opacity','0.85'));
3353
+ g.querySelectorAll('g[data-id]').forEach(n=>{ n.style.opacity = '1'; });
3354
+ nodeEls.forEach(({circle})=>{ circle.setAttribute('stroke','rgba(0,0,0,0.45)'); circle.setAttribute('stroke-width','0.8'); });
3355
+ }
3356
+ function selectResource(id){
3357
+ const ref = idx.byId.get(id);
3358
+ if (!ref) return;
3359
+ topologySelectedId = id;
3360
+ const neighbours = new Set([id]);
3361
+ const incoming = []; const outgoing = [];
3362
+ for (const e of drawEdges){
3363
+ if (e.from === id){ neighbours.add(e.to); outgoing.push(e); }
3364
+ if (e.to === id){ neighbours.add(e.from); incoming.push(e); }
3365
+ }
3366
+ g.querySelectorAll('path[data-from]').forEach(p=>{
3367
+ const on = neighbours.has(p.dataset.from) && neighbours.has(p.dataset.to);
3368
+ p.setAttribute('opacity', on ? '1' : '0.1');
3369
+ });
3370
+ g.querySelectorAll('g[data-id]').forEach(n=>{
3371
+ n.style.opacity = neighbours.has(n.dataset.id) ? '1' : '0.25';
3372
+ });
3373
+ const myEl = nodeEls.get(id);
3374
+ if (myEl){ myEl.circle.setAttribute('stroke', 'var(--accent, #58a6ff)'); myEl.circle.setAttribute('stroke-width','2.5'); }
3375
+ renderInspector(ref, incoming, outgoing);
3376
+ }
3377
+ svg.onclick = (ev)=>{
3378
+ const el = ev.target.closest && ev.target.closest('g[data-id]');
3379
+ if (!el){
3380
+ topologySelectedId = null;
3381
+ clearHighlight();
3382
+ showInspectorEmpty();
3383
+ return;
3384
+ }
3385
+ selectResource(el.dataset.id);
3386
+ };
3387
+
3388
+ function showInspectorEmpty(){
3389
+ document.getElementById('topology-inspector-empty').style.display = '';
3390
+ document.getElementById('topology-inspector-body').style.display = 'none';
3391
+ }
3392
+ function renderInspector(ref, incoming, outgoing){
3393
+ document.getElementById('topology-inspector-empty').style.display = 'none';
3394
+ const body = document.getElementById('topology-inspector-body');
3395
+ body.style.display = '';
3396
+ const labelEntries = Object.entries(ref.labels || {});
3397
+ const attrEntries = Object.entries(ref.attributes || {});
3398
+ function kvList(entries){
3399
+ if (entries.length === 0) return '<div class="muted" style="font-size: var(--fs-xs);">(none)</div>';
3400
+ return '<div style="display:grid; grid-template-columns: max-content 1fr; gap: 4px 10px; font-size: var(--fs-xs);">' +
3401
+ entries.map(([k,v])=>`<div class="muted" style="font-weight:600;">${esc(k)}</div><div style="font-family: 'JetBrains Mono', ui-monospace, monospace; word-break: break-all;">${esc(String(v))}</div>`).join('') +
3402
+ '</div>';
3403
+ }
3404
+ function neighbourList(edges, dir){
3405
+ if (edges.length === 0) return '<div class="muted" style="font-size: var(--fs-xs);">(none)</div>';
3406
+ return '<ul style="margin:0; padding-left: 0; list-style: none; display:flex; flex-direction:column; gap:4px;">' +
3407
+ edges.map(e=>{
3408
+ const otherId = dir === 'in' ? e.from : e.to;
3409
+ const other = idx.byId.get(otherId);
3410
+ const otherKind = other ? other.kind : '?';
3411
+ const otherName = other ? other.name : otherId;
3412
+ const c = topoKindColor(otherKind);
3413
+ return `<li style="display:flex; gap:6px; align-items:center; font-size: var(--fs-xs);">
3414
+ <span style="display:inline-block; width:8px; height:8px; border-radius:50%; background:${c}; flex-shrink:0;"></span>
3415
+ <span style="color: var(--text-muted); width: 78px; flex-shrink:0;">${esc(e.relation)}</span>
3416
+ <a href="#" data-jump="${esc(otherId)}" style="color: var(--accent, #58a6ff); text-decoration:none; word-break: break-all;">${esc(otherName)}</a>
3417
+ <span class="muted" style="flex-shrink:0;">(${esc(otherKind)})</span>
3418
+ </li>`;
3419
+ }).join('') + '</ul>';
3420
+ }
3421
+ body.innerHTML = `
3422
+ <div style="display:flex; align-items:flex-start; gap:8px;">
3423
+ <span style="display:inline-block; width:12px; height:12px; border-radius:50%; background:${topoKindColor(ref.kind)}; margin-top:5px; flex-shrink:0;"></span>
3424
+ <div style="min-width:0; flex:1;">
3425
+ <div style="font-weight:600; font-size: var(--fs-md); color: var(--text); word-break: break-all;">${esc(ref.name)}</div>
3426
+ <div style="display:flex; gap:6px; align-items:center; flex-wrap:wrap; margin-top: 4px;">
3427
+ <span class="badge">${esc(ref.kind)}</span>
3428
+ <span class="muted" style="font-size: var(--fs-xs);">source: ${esc(ref.source)}</span>
3429
+ </div>
3430
+ </div>
3431
+ </div>
3432
+ <div style="margin-top: 10px; font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 10px; color: var(--text-muted); word-break: break-all;">${esc(ref.id)}</div>
3433
+
3434
+ <div style="margin-top: 16px;">
3435
+ <div style="text-transform: uppercase; font-size: 10px; letter-spacing: 0.08em; color: var(--text-muted); margin-bottom: 6px;">Labels</div>
3436
+ ${kvList(labelEntries)}
3437
+ </div>
3438
+
3439
+ <div style="margin-top: 16px;">
3440
+ <div style="text-transform: uppercase; font-size: 10px; letter-spacing: 0.08em; color: var(--text-muted); margin-bottom: 6px;">Attributes</div>
3441
+ ${kvList(attrEntries.filter(([k])=>k!=='uid'))}
3442
+ </div>
3443
+
3444
+ <div style="margin-top: 16px;">
3445
+ <div style="text-transform: uppercase; font-size: 10px; letter-spacing: 0.08em; color: var(--text-muted); margin-bottom: 6px;">Outgoing (${outgoing.length})</div>
3446
+ ${neighbourList(outgoing, 'out')}
3447
+ </div>
3448
+
3449
+ <div style="margin-top: 12px;">
3450
+ <div style="text-transform: uppercase; font-size: 10px; letter-spacing: 0.08em; color: var(--text-muted); margin-bottom: 6px;">Incoming (${incoming.length})</div>
3451
+ ${neighbourList(incoming, 'in')}
3452
+ </div>
3453
+
3454
+ <div style="margin-top: 18px; padding-top: 12px; border-top: 1px solid var(--border);">
3455
+ <div style="text-transform: uppercase; font-size: 10px; letter-spacing: 0.08em; color: var(--text-muted); margin-bottom: 6px;">Linked telemetry</div>
3456
+ <div class="muted" style="font-size: var(--fs-xs);">No metrics or logs linked to this resource. When demo workloads run inside the cluster and emit Prometheus/Loki signals under matching service labels, charts will appear here.</div>
3457
+ </div>
3458
+ `;
3459
+ // Wire neighbour links to navigate inside the graph.
3460
+ body.querySelectorAll('a[data-jump]').forEach(a=>{
3461
+ a.addEventListener('click', (ev)=>{
3462
+ ev.preventDefault();
3463
+ selectResource(a.dataset.jump);
3464
+ });
3465
+ });
3466
+ }
3467
+
3468
+ // Re-apply selection across re-renders. If the previously-selected
3469
+ // resource still exists, keep the highlight + inspector pinned to it
3470
+ // so a poll-driven refresh doesn't drag the user back to the empty
3471
+ // state. If it's gone (deleted), fall back to empty.
3472
+ if (topologySelectedId && idx.byId.has(topologySelectedId)){
3473
+ selectResource(topologySelectedId);
3474
+ } else {
3475
+ topologySelectedId = null;
3476
+ showInspectorEmpty();
3477
+ }
3478
+ topologyGraphState = { positions, view };
3479
+ }
3480
+
2658
3481
  // --- Utils ---
2659
3482
  function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML;}
2660
3483
  async function refresh(){await Promise.all([loadSources(),loadServices()]);}