feat (ui): netwerk map functionaliteit verder uitgebreid met detail info per netwerk
This commit is contained in:
@@ -600,3 +600,40 @@ pre{
|
||||
|
||||
.graphDim{ opacity: 0.18; }
|
||||
.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; }
|
||||
@@ -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})`);
|
||||
});
|
||||
|
||||
graphCtx = { svg, g, sim, model };
|
||||
// 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;
|
||||
|
||||
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() {
|
||||
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)}…`;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -171,7 +171,14 @@
|
||||
Kaartweergave is actief. (STAP 3A-1: alleen layout/controls. D3 rendering komt in 3C.)
|
||||
</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 class="legendTitle">Legenda</div>
|
||||
<div class="legendRow"><span class="legendSwatch net"></span> Netwerk</div>
|
||||
|
||||
Reference in New Issue
Block a user