feat (ui): netwerk map functionaliteit verder uitgebreid met detail info per netwerk

This commit is contained in:
kodi
2026-02-23 16:27:07 +01:00
parent 001b745e2f
commit 289d222707
3 changed files with 267 additions and 41 deletions
+37
View File
@@ -600,3 +600,40 @@ pre{
.graphDim{ opacity: 0.18; } .graphDim{ opacity: 0.18; }
.graphActive{ opacity: 1; stroke: rgba(255,255,255,0.55); stroke-width: 2; } .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; }
+222 -40
View File
@@ -41,6 +41,8 @@
list: null, list: null,
inspectCache: new Map(), inspectCache: new Map(),
view: 'table', // 'table' | 'map' view: 'table', // 'table' | 'map'
mapMode: 'global', // 'global' | 'detail'
selectedNetwork: null, // string
filters: { filters: {
q: '', q: '',
connectedOnly: false, connectedOnly: false,
@@ -72,18 +74,10 @@
}); });
} }
if (v === 'map') { if (v === 'map') {
try { openGlobalMap();
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);
} }
} }
}
function toggleNetworkRow(name) { function toggleNetworkRow(name) {
if (state.expanded.has(name)) state.expanded.delete(name); if (state.expanded.has(name)) state.expanded.delete(name);
else state.expanded.add(name); else state.expanded.add(name);
@@ -528,9 +522,82 @@
return { nodes, links, meta }; 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; let graphCtx = null;
function renderGraph(model) { function renderGraph(model, opts = {}) {
const host = document.getElementById('networksMapHost'); const host = document.getElementById('networksMapHost');
if (!host) return; if (!host) return;
@@ -546,11 +613,11 @@
const g = svg.append('g'); const g = svg.append('g');
// zoom/pan // zoom/pan
svg.call( const zoom = d3.zoom()
d3.zoom()
.scaleExtent([0.2, 2.5]) .scaleExtent([0.2, 2.5])
.on('zoom', (ev) => g.attr('transform', ev.transform)) .on('zoom', (ev) => g.attr('transform', ev.transform));
);
svg.call(zoom);
// links // links
const link = g.append('g') const link = g.append('g')
@@ -618,15 +685,20 @@
}); });
node.on('click', (ev, d) => { node.on('click', (ev, d) => {
if (d.type === 'network') {
openNetworkDetail(d.key);
return;
}
const s = document.getElementById('networksMapStatus'); const s = document.getElementById('networksMapStatus');
if (s) s.textContent = `Geselecteerd: ${d.type} ${d.key}`; if (s) s.textContent = `Geselecteerd: ${d.type} ${d.key}`;
}); });
// simulation // simulation
const sim = d3.forceSimulation(model.nodes) const sim = d3.forceSimulation(model.nodes)
.force('link', d3.forceLink(model.links).id(d => d.id).distance(80)) .force('link', d3.forceLink(model.links).id(d => d.id).distance(45))
.force('charge', d3.forceManyBody().strength(-220)) .force('charge', d3.forceManyBody().strength(-40))
.force('center', d3.forceCenter(w / 2, h / 2)) .force('center', d3.forceCenter(w / 2, h / 2).strength(0.15))
.force('collide', d3.forceCollide().radius(d => d.type === 'network' ? 18 : 16)) .force('collide', d3.forceCollide().radius(d => d.type === 'network' ? 18 : 16))
.on('tick', () => { .on('tick', () => {
link link
@@ -637,10 +709,91 @@
node.attr('transform', d => `translate(${d.x},${d.y})`); node.attr('transform', d => `translate(${d.x},${d.y})`);
}); });
// detail-mode: pin netwerk in het midden
graphCtx = { svg, g, sim, model }; 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;
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
? `<ul class="mapDetailList">${containers.map(c => {
const label = esc(c.name || c.id.slice(0, 12));
return `<li><span class="mapDetailLink" data-ctr="${esc(c.id)}">${label}</span></li>`;
}).join('')}</ul>`
: `<div class="muted">Geen containers op dit netwerk.</div>`;
body.innerHTML = `
<div class="mapDetailGrid">
<div class="mapDetailKey">Driver</div><div>${esc(driver || '—')}</div>
<div class="mapDetailKey">Subnets</div><div>${esc(subnets || '—')}</div>
<div class="mapDetailKey">Internal</div><div>${esc(internal)}</div>
<div class="mapDetailKey">DNS</div><div>${esc(dns)}</div>
<div class="mapDetailKey">IPv6</div><div>${esc(ipv6)}</div>
<div class="mapDetailKey">Containers</div><div>${containers.length}</div>
</div>
${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() { function renderNetworks() {
const tbody = document.getElementById('networksTbody'); const tbody = document.getElementById('networksTbody');
@@ -742,21 +895,7 @@
const btn = document.getElementById('networksRefreshBtn'); const btn = document.getElementById('networksRefreshBtn');
if (btn && !btn.dataset.bound) { if (btn && !btn.dataset.bound) {
btn.dataset.bound = '1'; btn.dataset.bound = '1';
resetBtn.addEventListener('click', () => { btn.addEventListener('click', refresh);
if (!graphCtx) return;
// reset zoom
graphCtx.svg
.transition()
.duration(400)
.call(
d3.zoom().transform,
d3.zoomIdentity
);
// restart simulation
graphCtx.sim.alpha(0.6).restart();
});
} }
// View toggle (Tabel/Kaart) // View toggle (Tabel/Kaart)
@@ -793,13 +932,11 @@
graphCtx.svg graphCtx.svg
.transition() .transition()
.duration(400) .duration(400)
.call( .call(graphCtx.zoom.transform, d3.zoomIdentity);
d3.zoom().transform,
d3.zoomIdentity
);
// restart simulation // 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.dataset.bound = '1';
layoutBtn.addEventListener('click', () => { layoutBtn.addEventListener('click', () => {
if (!graphCtx) return; 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)}`;
}); });
} }
+7
View File
@@ -171,7 +171,14 @@
Kaartweergave is actief. (STAP 3A-1: alleen layout/controls. D3 rendering komt in 3C.) Kaartweergave is actief. (STAP 3A-1: alleen layout/controls. D3 rendering komt in 3C.)
</div> </div>
</div> </div>
<div id="networksDetailPanel" class="mapDetail" style="display:none;">
<div class="mapDetailHeader">
<button class="btn small ghost" type="button" id="networksMapBackBtn">← Terug</button>
<div class="mapDetailTitle" id="networksDetailTitle">Netwerk</div>
</div>
<div class="mapDetailBody" id="networksDetailBody"></div>
</div>
<div id="networksMapLegend" class="mapLegend" style="display:none;"> <div id="networksMapLegend" class="mapLegend" style="display:none;">
<div class="legendTitle">Legenda</div> <div class="legendTitle">Legenda</div>
<div class="legendRow"><span class="legendSwatch net"></span> Netwerk</div> <div class="legendRow"><span class="legendSwatch net"></span> Netwerk</div>