diff --git a/webui/html/assets/css/app.css b/webui/html/assets/css/app.css index 9c482db..1832a44 100644 --- a/webui/html/assets/css/app.css +++ b/webui/html/assets/css/app.css @@ -599,4 +599,41 @@ pre{ } .graphDim{ opacity: 0.18; } -.graphActive{ opacity: 1; stroke: rgba(255,255,255,0.55); stroke-width: 2; } \ No newline at end of file +.graphActive{ opacity: 1; stroke: rgba(255,255,255,0.55); stroke-width: 2; } + +/* ===== Netwerken detailpaneel ===== */ +.mapDetail{ + margin-top: 10px; + padding: 10px 12px; + border: 1px solid rgba(255,255,255,0.10); + background: rgba(0,0,0,0.18); + border-radius: 14px; +} + +.mapDetailHeader{ + display:flex; + align-items:center; + gap: 10px; + margin-bottom: 10px; +} + +.mapDetailTitle{ + font-weight: 800; +} + +.mapDetailBody{ + font-size: 13px; + opacity: 0.92; +} + +.mapDetailGrid{ + display:grid; + grid-template-columns: 140px 1fr; + gap: 6px 10px; + margin-bottom: 10px; +} + +.mapDetailKey{ opacity: 0.75; } +.mapDetailList{ margin: 8px 0 0 0; padding-left: 18px; } +.mapDetailList li{ margin: 4px 0; } +.mapDetailLink{ cursor: pointer; text-decoration: underline; } \ No newline at end of file diff --git a/webui/html/assets/js/tabs/networks.js b/webui/html/assets/js/tabs/networks.js index 9d8c913..5221070 100644 --- a/webui/html/assets/js/tabs/networks.js +++ b/webui/html/assets/js/tabs/networks.js @@ -41,6 +41,8 @@ list: null, inspectCache: new Map(), view: 'table', // 'table' | 'map' + mapMode: 'global', // 'global' | 'detail' + selectedNetwork: null, // string filters: { q: '', connectedOnly: false, @@ -72,16 +74,8 @@ }); } if (v === 'map') { - try { - const model = buildGlobalGraphModel(); - const s = document.getElementById('networksMapStatus'); - if (s) s.textContent = `Kaart: ${model.meta.nodes} nodes, ${model.meta.links} links`; - renderGraph(model); - } catch (e) { - console.error('[networks] buildGlobalGraphModel failed', e); - } + openGlobalMap(); } - } function toggleNetworkRow(name) { @@ -528,9 +522,82 @@ return { nodes, links, meta }; } + function buildNetworkDetailGraphModel(networkName) { + const usage = state.usage || {}; + const byNetwork = usage.byNetwork || {}; + const byContainerMeta = usage.byContainerMeta || {}; + + // netwerk meta zoeken + const list = state.list?.networks || []; + const netMeta = list.find(n => n?.name === networkName) || null; + + const nodes = []; + const links = []; + const nodeIndex = new Map(); + + function addNode(key, node) { + if (nodeIndex.has(key)) return key; + node.id = key; + nodes.push(node); + nodeIndex.set(key, 1); + return key; + } + + const nkey = `net:${networkName}`; + addNode(nkey, { + type: 'network', + key: networkName, + label: _ellipsize(networkName, 26), + meta: netMeta || {} + }); + + const netUsage = byNetwork[networkName]; + const ctrs = (netUsage && Array.isArray(netUsage.containers)) ? netUsage.containers : []; + + const idByName = new Map(); + for (const c of ctrs) { + const ckey = `ctr:${c.id}`; + addNode(ckey, { + type: 'container', + key: c.id, + label: _ellipsize(c.name || c.id.slice(0, 12), 26), + name: c.name || '', + pod: c.pod || '', + networkMode: c.networkMode || '', + }); + if (c.name) idByName.set(String(c.name), String(c.id)); + + links.push({ type: 'attach', source: nkey, target: ckey }); + } + + // shared-netns links alleen binnen deze set containers + for (const [ctrKey, cm] of Object.entries(byContainerMeta)) { + const mode = String(cm?.networkMode || ''); + if (!mode.startsWith('container:')) continue; + + const ownerId = mode.split(':', 2)[1] || ''; + if (!ownerId) continue; + + const childId = idByName.get(String(ctrKey)) || String(ctrKey); + + const ownerNode = `ctr:${ownerId}`; + const childNode = `ctr:${childId}`; + + if (nodeIndex.has(ownerNode) && nodeIndex.has(childNode)) { + links.push({ type: 'shared', source: ownerNode, target: childNode }); + } + } + + return { + nodes, + links, + meta: { view: 'detail', networkName } + }; + } + let graphCtx = null; - function renderGraph(model) { + function renderGraph(model, opts = {}) { const host = document.getElementById('networksMapHost'); if (!host) return; @@ -546,11 +613,11 @@ const g = svg.append('g'); // zoom/pan - svg.call( - d3.zoom() - .scaleExtent([0.2, 2.5]) - .on('zoom', (ev) => g.attr('transform', ev.transform)) - ); + const zoom = d3.zoom() + .scaleExtent([0.2, 2.5]) + .on('zoom', (ev) => g.attr('transform', ev.transform)); + + svg.call(zoom); // links const link = g.append('g') @@ -618,15 +685,20 @@ }); node.on('click', (ev, d) => { + if (d.type === 'network') { + openNetworkDetail(d.key); + return; + } + const s = document.getElementById('networksMapStatus'); if (s) s.textContent = `Geselecteerd: ${d.type} ${d.key}`; }); // simulation const sim = d3.forceSimulation(model.nodes) - .force('link', d3.forceLink(model.links).id(d => d.id).distance(80)) - .force('charge', d3.forceManyBody().strength(-220)) - .force('center', d3.forceCenter(w / 2, h / 2)) + .force('link', d3.forceLink(model.links).id(d => d.id).distance(45)) + .force('charge', d3.forceManyBody().strength(-40)) + .force('center', d3.forceCenter(w / 2, h / 2).strength(0.15)) .force('collide', d3.forceCollide().radius(d => d.type === 'network' ? 18 : 16)) .on('tick', () => { link @@ -637,10 +709,91 @@ node.attr('transform', d => `translate(${d.x},${d.y})`); }); + // detail-mode: pin netwerk in het midden + if (opts.pinNetwork) { + const pinId = opts.pinNetwork; + const pinned = model.nodes.find(n => n.id === pinId); + if (pinned) { + pinned.fx = w / 2; + pinned.fy = h / 2; + } + } + graphCtx = { svg, g, sim, model, zoom }; + } + + function openNetworkDetail(networkName) { + state.mapMode = 'detail'; + state.selectedNetwork = networkName; - graphCtx = { svg, g, sim, model }; + const model = buildNetworkDetailGraphModel(networkName); + renderGraph(model, { pinNetwork: `net:${networkName}` }); + + showDetailPanel(networkName); + + const s = document.getElementById('networksMapStatus'); + if (s) s.textContent = `Detail: ${networkName}`; } + function showDetailPanel(networkName) { + const panel = document.getElementById('networksDetailPanel'); + const title = document.getElementById('networksDetailTitle'); + const body = document.getElementById('networksDetailBody'); + if (!panel || !title || !body) return; + + // meta uit state.list (kan null zijn bij mode-netwerken) + const list = Array.isArray(state.list?.networks) ? state.list.networks : []; + const meta = list.find(n => (n?.name || n?.Name) === networkName) || { name: networkName, driver: 'mode' }; + + // containers uit usage + const usage = state.usage?.byNetwork?.[networkName]; + const containers = Array.isArray(usage?.containers) ? usage.containers : []; + + title.textContent = networkName; + + const driver = String(meta?.driver || meta?.Driver || ''); + const subnets = fmtSubnets(meta).join(', '); + const internal = meta?.internal === true ? 'ja' : 'nee'; + const dns = meta?.dnsEnabled === true ? 'ja' : 'nee'; + const ipv6 = meta?.ipv6Enabled === true ? 'ja' : 'nee'; + + const listHtml = containers.length + ? `` + : `
Geen containers op dit netwerk.
`; + + body.innerHTML = ` +
+
Driver
${esc(driver || '—')}
+
Subnets
${esc(subnets || '—')}
+
Internal
${esc(internal)}
+
DNS
${esc(dns)}
+
IPv6
${esc(ipv6)}
+
Containers
${containers.length}
+
+ ${listHtml} + `; + + panel.style.display = ''; + } + + function hideDetailPanel() { + const panel = document.getElementById('networksDetailPanel'); + if (panel) panel.style.display = 'none'; + } + + function openGlobalMap() { + state.mapMode = 'global'; + state.selectedNetwork = null; + + const model = buildGlobalGraphModel(); + renderGraph(model); + hideDetailPanel(); + + const s = document.getElementById('networksMapStatus'); + if (s) s.textContent = `Kaart: ${model.meta.nodes} nodes, ${model.meta.links} links`; + } function renderNetworks() { const tbody = document.getElementById('networksTbody'); @@ -742,21 +895,7 @@ const btn = document.getElementById('networksRefreshBtn'); if (btn && !btn.dataset.bound) { btn.dataset.bound = '1'; - resetBtn.addEventListener('click', () => { - if (!graphCtx) return; - - // reset zoom - graphCtx.svg - .transition() - .duration(400) - .call( - d3.zoom().transform, - d3.zoomIdentity - ); - - // restart simulation - graphCtx.sim.alpha(0.6).restart(); - }); + btn.addEventListener('click', refresh); } // View toggle (Tabel/Kaart) @@ -793,13 +932,11 @@ graphCtx.svg .transition() .duration(400) - .call( - d3.zoom().transform, - d3.zoomIdentity - ); + .call(graphCtx.zoom.transform, d3.zoomIdentity); // restart simulation - graphCtx.sim.alpha(0.6).restart(); + graphCtx.model.nodes.forEach(n => { n.fx = null; n.fy = null; }); + graphCtx.sim.alpha(1).restart(); }); } @@ -808,7 +945,52 @@ layoutBtn.dataset.bound = '1'; layoutBtn.addEventListener('click', () => { if (!graphCtx) return; - graphCtx.sim.alpha(1).restart(); + graphCtx.model.nodes.forEach(n => { n.fx = null; n.fy = null; }); + graphCtx.sim.alpha(1.5).restart(); + }); + } + + const backBtn = document.getElementById('networksMapBackBtn'); + if (backBtn && !backBtn.dataset.bound) { + backBtn.dataset.bound = '1'; + backBtn.addEventListener('click', () => { + openGlobalMap(); + const panel = document.getElementById('networksDetailPanel'); + if (panel) panel.style.display = 'none'; + }); + } + + const detailBody = document.getElementById('networksDetailBody'); + if (detailBody && !detailBody.dataset.bound) { + detailBody.dataset.bound = '1'; + detailBody.addEventListener('click', (ev) => { + const t = ev.target; + if (!(t instanceof HTMLElement)) return; + const el = t.closest('[data-ctr]'); + if (!el) return; + + const id = el.getAttribute('data-ctr'); + if (!id || !graphCtx) return; + + // highlight: dim alles, highlight gekozen container + links + const targetId = `ctr:${id}`; + graphCtx.g.selectAll('.graphNode').classed('graphDim', true); + graphCtx.g.selectAll('.graphLink').classed('graphDim', true).classed('graphActive', false); + + graphCtx.g.selectAll('.graphNode').each(function(d) { + if (d?.id === targetId) d3.select(this).classed('graphDim', false); + }); + + graphCtx.g.selectAll('.graphLink').each(function(l) { + const sid = l.source?.id || l.source; + const tid = l.target?.id || l.target; + if (sid === targetId || tid === targetId) { + d3.select(this).classed('graphDim', false).classed('graphActive', true); + } + }); + + const s = document.getElementById('networksMapStatus'); + if (s) s.textContent = `Container: ${id.slice(0, 12)}…`; }); } diff --git a/webui/html/index.html b/webui/html/index.html index 215f0c6..3db2063 100644 --- a/webui/html/index.html +++ b/webui/html/index.html @@ -171,7 +171,14 @@ Kaartweergave is actief. (STAP 3A-1: alleen layout/controls. D3 rendering komt in 3C.) +