1074 lines
35 KiB
JavaScript
1074 lines
35 KiB
JavaScript
// webui/html/assets/js/tabs/networks.js
|
|
(function () {
|
|
// Gebruik gedeelde helpers uit index.html (staan op window in non-module scripts)
|
|
const apiGet = window.apiGet || (async (path) => {
|
|
const r = await fetch('/api' + path, { headers: { 'Accept': 'application/json' } });
|
|
if (!r.ok) {
|
|
const t = await r.text().catch(() => '');
|
|
throw new Error(`HTTP ${r.status} ${path}: ${t}`);
|
|
}
|
|
return r.json();
|
|
});
|
|
|
|
const esc = window.esc || ((s) => String(s)
|
|
.replaceAll('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll("'", ''')
|
|
);
|
|
|
|
function cssId(s) {
|
|
return String(s).replaceAll(/[^a-zA-Z0-9_-]/g, '_');
|
|
}
|
|
|
|
async function fetchNetworksUsage() {
|
|
return apiGet('/networks/usage');
|
|
}
|
|
|
|
async function fetchNetworksList() {
|
|
// returns { networks: [...] }
|
|
return apiGet('/networks');
|
|
}
|
|
|
|
async function fetchNetworkInspect(name) {
|
|
return apiGet('/networks/' + encodeURIComponent(name));
|
|
}
|
|
|
|
const state = {
|
|
expanded: new Set(),
|
|
usage: null,
|
|
list: null,
|
|
inspectCache: new Map(),
|
|
view: 'table', // 'table' | 'map'
|
|
mapMode: 'global', // 'global' | 'detail'
|
|
selectedNetwork: null, // string
|
|
filters: {
|
|
q: '',
|
|
connectedOnly: false,
|
|
hideDefaults: true,
|
|
sharedOnly: false,
|
|
sort: 'name_asc',
|
|
},
|
|
};
|
|
function setNetworksView(view) {
|
|
const v = (view === 'map') ? 'map' : 'table';
|
|
state.view = v;
|
|
|
|
const table = document.getElementById('networksTable');
|
|
const relCard = document.getElementById('networksRelationsCard');
|
|
const mapWrap = document.getElementById('networksMapWrap');
|
|
|
|
if (table) table.style.display = (v === 'table') ? '' : 'none';
|
|
if (relCard) relCard.style.display = (v === 'table') ? '' : 'none';
|
|
if (mapWrap) mapWrap.style.display = (v === 'map') ? '' : 'none';
|
|
|
|
// Toggle active state in segmented control
|
|
const toggle = document.getElementById('networksViewToggle');
|
|
if (toggle) {
|
|
const btns = toggle.querySelectorAll('button[data-view]');
|
|
btns.forEach(b => {
|
|
const bv = b.getAttribute('data-view');
|
|
if (bv === v) b.classList.add('active');
|
|
else b.classList.remove('active');
|
|
});
|
|
}
|
|
if (v === 'map') {
|
|
openGlobalMap();
|
|
}
|
|
}
|
|
|
|
function toggleNetworkRow(name) {
|
|
if (state.expanded.has(name)) state.expanded.delete(name);
|
|
else state.expanded.add(name);
|
|
renderNetworks();
|
|
}
|
|
|
|
async function refresh() {
|
|
const statusEl = document.getElementById('networksStatus');
|
|
if (statusEl) statusEl.textContent = 'Bezig met laden...';
|
|
|
|
try {
|
|
const [usage, list] = await Promise.all([
|
|
fetchNetworksUsage(),
|
|
fetchNetworksList(),
|
|
]);
|
|
state.usage = usage;
|
|
state.list = list;
|
|
if (statusEl) statusEl.textContent = `Laatst geladen: ${new Date().toLocaleString()}`;
|
|
renderNetworksSummary();
|
|
renderNetworks();
|
|
} catch (e) {
|
|
console.error(e);
|
|
if (statusEl) statusEl.textContent = 'Fout: ' + (e?.message || e);
|
|
}
|
|
}
|
|
|
|
function computeNetworkStats() {
|
|
const totalNetworks = Array.isArray(state.list?.networks) ? state.list.networks.length : 0;
|
|
const byNetwork = state.usage?.byNetwork || {};
|
|
const usedNetworks = Object.keys(byNetwork).length;
|
|
|
|
let connectedContainers = 0;
|
|
for (const v of Object.values(byNetwork)) {
|
|
const cs = v?.containers;
|
|
if (Array.isArray(cs)) connectedContainers += cs.length;
|
|
}
|
|
|
|
const unusedNetworks = Math.max(0, totalNetworks - usedNetworks);
|
|
|
|
const byMeta = state.usage?.byContainerMeta || {};
|
|
let sharedNetns = 0;
|
|
for (const meta of Object.values(byMeta)) {
|
|
if (meta?.networkMode && String(meta.networkMode).startsWith('container:')) sharedNetns += 1;
|
|
}
|
|
|
|
return { totalNetworks, usedNetworks, unusedNetworks, connectedContainers, sharedNetns };
|
|
}
|
|
|
|
function renderNetworksSummary() {
|
|
const host = document.getElementById('networksSummary');
|
|
if (!host) return;
|
|
|
|
// beginstatus
|
|
if (!state.list && !state.usage) {
|
|
host.innerHTML = `
|
|
<div class="statCard"><div class="statValue">-</div><div class="statLabel">Netwerken</div></div>
|
|
<div class="statCard"><div class="statValue">-</div><div class="statLabel">Verbonden containers</div></div>
|
|
<div class="statCard"><div class="statValue">-</div><div class="statLabel">Ongebruikt</div></div>
|
|
<div class="statCard"><div class="statValue">-</div><div class="statLabel">Shared netns</div></div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
const s = computeNetworkStats();
|
|
host.innerHTML = `
|
|
<div class="statCard">
|
|
<div class="statValue">${s.totalNetworks}</div>
|
|
<div class="statLabel">Netwerken</div>
|
|
<div class="statHint">${s.usedNetworks} in gebruik</div>
|
|
</div>
|
|
<div class="statCard">
|
|
<div class="statValue">${s.connectedContainers}</div>
|
|
<div class="statLabel">Verbonden containers</div>
|
|
</div>
|
|
<div class="statCard">
|
|
<div class="statValue">${s.unusedNetworks}</div>
|
|
<div class="statLabel">Ongebruikt</div>
|
|
</div>
|
|
<div class="statCard">
|
|
<div class="statValue">${s.sharedNetns}</div>
|
|
<div class="statLabel">Shared netns</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderNetworkUsersListHTML(netName, containers) {
|
|
if (!containers.length) {
|
|
return `<div class="muted">Geen containers gevonden op dit netwerk.</div>`;
|
|
}
|
|
|
|
const rows = containers.map(c => {
|
|
const cname = c.name || '?';
|
|
const pod = c.pod || '';
|
|
const shared = c.networkMode && String(c.networkMode).startsWith('container:');
|
|
const via = c.networkOwnerName ? `via ${c.networkOwnerName}` : (c.networkOwnerId ? `via ${c.networkOwnerId}` : '');
|
|
|
|
const badges = [];
|
|
if (pod) badges.push(`<span class="badge">pod: ${esc(pod)}</span>`);
|
|
if (shared) badges.push(`<span class="badge">shared netns</span>`);
|
|
if (via) badges.push(`<span class="badge">${esc(via)}</span>`);
|
|
|
|
return `<li>
|
|
<b>${esc(cname)}</b>
|
|
${badges.length ? `<div class="row gap" style="margin-top:4px; flex-wrap:wrap;">${badges.join('')}</div>` : ``}
|
|
</li>`;
|
|
}).join('');
|
|
|
|
return `<div>
|
|
<div class="muted" style="margin-bottom:6px;">Containers op <b>${esc(netName)}</b>:</div>
|
|
<ul style="margin:0; padding-left:18px;">${rows}</ul>
|
|
</div>`;
|
|
}
|
|
|
|
function renderNetworksRelationsHTML(usage) {
|
|
const byContainer = usage.byContainer || {};
|
|
const byMeta = usage.byContainerMeta || {};
|
|
|
|
const multi = Object.entries(byContainer)
|
|
.filter(([_, nets]) => Array.isArray(nets) && nets.length > 1)
|
|
.sort((a, b) => a[0].localeCompare(b[0]));
|
|
|
|
const shared = Object.entries(byMeta)
|
|
.filter(([_, meta]) => meta && meta.networkMode && String(meta.networkMode).startsWith('container:'))
|
|
.sort((a, b) => a[0].localeCompare(b[0]));
|
|
|
|
let html = '';
|
|
|
|
html += `<h3 style="margin:0 0 6px 0;">Containers met meerdere netwerken</h3>`;
|
|
if (!multi.length) {
|
|
html += `<div class="muted" style="margin-bottom:12px;">Geen.</div>`;
|
|
} else {
|
|
html += `<ul style="margin:0 0 12px 0; padding-left:18px;">` + multi.map(([name, nets]) =>
|
|
`<li><b>${esc(name)}</b> → ${esc(nets.join(', '))}</li>`
|
|
).join('') + `</ul>`;
|
|
}
|
|
|
|
html += `<h3 style="margin:0 0 6px 0;">Shared network namespace</h3>`;
|
|
if (!shared.length) {
|
|
html += `<div class="muted">Geen.</div>`;
|
|
} else {
|
|
html += `<ul style="margin:0; padding-left:18px;">` + shared.map(([name, meta]) => {
|
|
const ownerName = meta.networkOwnerName || '';
|
|
const ownerId = meta.networkOwnerId || '';
|
|
const owner = ownerName || ownerId || '?';
|
|
|
|
// Probeer netwerken van de owner te tonen (bv. ["pasta"] of ["none"])
|
|
const ownerNets = ownerName && Array.isArray(byContainer[ownerName]) ? byContainer[ownerName] : [];
|
|
const mode = ownerNets.find(n => n === 'pasta' || n === 'none' || n === 'host') || '';
|
|
|
|
const extra = mode ? ` <span class="muted">(mode: ${esc(mode)})</span>` : '';
|
|
|
|
return `<li><b>${esc(name)}</b> deelt netwerkstack met <b>${esc(owner)}</b>${extra}</li>`;
|
|
}).join('') + `</ul>`;
|
|
}
|
|
return html;
|
|
}
|
|
|
|
async function onInspectNetwork(name) {
|
|
const box = document.getElementById('inspectBox-' + cssId(name));
|
|
const status = document.getElementById('inspectStatus-' + cssId(name));
|
|
if (status) status.textContent = 'Inspect laden...';
|
|
|
|
try {
|
|
let data = state.inspectCache.get(name);
|
|
if (!data) {
|
|
data = await fetchNetworkInspect(name);
|
|
state.inspectCache.set(name, data);
|
|
}
|
|
if (box) {
|
|
box.style.display = '';
|
|
box.textContent = JSON.stringify(data, null, 2);
|
|
}
|
|
if (status) status.textContent = '';
|
|
} catch (e) {
|
|
console.error(e);
|
|
if (status) status.textContent = 'Fout: ' + (e?.message || e);
|
|
}
|
|
}
|
|
function isDefaultNetworkName(name) {
|
|
return name === 'podman' || name === 'podman-default-kube-network';
|
|
}
|
|
|
|
function fmtSubnets(net) {
|
|
// We proberen verschillende mogelijke shapes:
|
|
// - net.subnets: ["10.88.0.0/16"]
|
|
// - net.subnets: [{subnet:"10.88.0.0/16", gateway:"10.88.0.1"}]
|
|
// - net.ipam / net.ipamConfig: unknown => tonen we niets i.p.v. crash
|
|
const s = net?.subnets;
|
|
if (!s) return [];
|
|
if (Array.isArray(s)) {
|
|
return s.map(x => {
|
|
if (typeof x === 'string') return x;
|
|
if (x && typeof x === 'object') {
|
|
const subnet = x.subnet || x.Subnet || '';
|
|
const gw = x.gateway || x.Gateway || '';
|
|
return gw ? `${subnet} (gw ${gw})` : subnet;
|
|
}
|
|
return String(x);
|
|
}).filter(Boolean);
|
|
}
|
|
return [String(s)];
|
|
}
|
|
|
|
function flagsHTML(netName, netMeta) {
|
|
const badges = [];
|
|
|
|
if (isDefaultNetworkName(netName)) badges.push(`<span class="badge">default</span>`);
|
|
if (netMeta?.internal === true) badges.push(`<span class="badge">internal</span>`);
|
|
if (netMeta?.dnsEnabled === true) badges.push(`<span class="badge">dns</span>`);
|
|
if (netMeta?.ipv6Enabled === true) badges.push(`<span class="badge">ipv6</span>`);
|
|
|
|
// driver badge (maar niet voor mode, die tonen we in driver kolom)
|
|
if (netMeta?.driver) {
|
|
const d = String(netMeta.driver);
|
|
if (d !== 'mode') {
|
|
badges.push(`<span class="badge">${esc(d)}</span>`);
|
|
}
|
|
}
|
|
|
|
return badges.join(' ');
|
|
}
|
|
|
|
function buildNetworksViewModel() {
|
|
const list = Array.isArray(state.list?.networks) ? state.list.networks : [];
|
|
const usageByNetwork = state.usage?.byNetwork || {};
|
|
|
|
// 1) Start met de lijst uit /api/networks (leidend voor metadata/unused)
|
|
const items = list
|
|
.map(n => {
|
|
const name = n?.name || n?.Name || '';
|
|
const usage = usageByNetwork[name] || null;
|
|
const containers = Array.isArray(usage?.containers) ? usage.containers : [];
|
|
return {
|
|
name,
|
|
meta: n || {},
|
|
containers,
|
|
containerCount: containers.length,
|
|
};
|
|
})
|
|
.filter(x => x.name); // sanity
|
|
|
|
// 2) Voeg "usage-only" netwerken toe (zoals pasta/host/none) die niet in /api/networks staan
|
|
const seen = new Set(items.map(x => x.name));
|
|
for (const netName of Object.keys(usageByNetwork)) {
|
|
if (seen.has(netName)) continue;
|
|
|
|
const usage = usageByNetwork[netName] || null;
|
|
const containers = Array.isArray(usage?.containers) ? usage.containers : [];
|
|
|
|
// meta leeg, maar we geven driver=mode zodat het netjes in de UI komt
|
|
items.push({
|
|
name: netName,
|
|
meta: { driver: 'mode' },
|
|
containers,
|
|
containerCount: containers.length,
|
|
});
|
|
seen.add(netName);
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
function applyFiltersAndSort(vm) {
|
|
const q = (state.filters.q || '').trim().toLowerCase();
|
|
const connectedOnly = !!state.filters.connectedOnly;
|
|
const hideDefaults = !!state.filters.hideDefaults;
|
|
const sharedOnly = !!state.filters.sharedOnly;
|
|
|
|
const byMeta = state.usage?.byContainerMeta || {};
|
|
|
|
let out = vm.slice();
|
|
|
|
if (hideDefaults) {
|
|
out = out.filter(n => !isDefaultNetworkName(n.name));
|
|
}
|
|
|
|
if (connectedOnly) {
|
|
out = out.filter(n => n.containerCount > 0);
|
|
}
|
|
|
|
if (sharedOnly) {
|
|
const byMeta = state.usage?.byContainerMeta || {};
|
|
const byContainer = state.usage?.byContainer || {};
|
|
|
|
// alle containers met networkMode=container:...
|
|
const sharedEntries = Object.entries(byMeta)
|
|
.filter(([_, meta]) => String(meta?.networkMode ?? "").startsWith("container:"));
|
|
|
|
// welke netwerken zijn "relevant" voor shared netns?
|
|
const sharedNetworks = new Set();
|
|
|
|
// helper: voeg netwerken van containerKey toe via byContainer
|
|
function addNetworksFor(containerKey) {
|
|
const nets = byContainer[containerKey];
|
|
if (Array.isArray(nets)) {
|
|
for (const n of nets) sharedNetworks.add(n);
|
|
}
|
|
}
|
|
|
|
for (const [name, meta] of sharedEntries) {
|
|
addNetworksFor(name); // netwerken van de shared container zelf
|
|
|
|
// netwerken van de owner/infra (heel belangrijk in jouw geval)
|
|
const ownerName = meta?.networkOwnerName;
|
|
if (ownerName) addNetworksFor(ownerName);
|
|
}
|
|
|
|
out = out.filter(n => sharedNetworks.has(n.name));
|
|
}
|
|
|
|
if (q) {
|
|
out = out.filter(n => {
|
|
const nameHit = n.name.toLowerCase().includes(q);
|
|
const driver = (n.meta?.driver || '').toLowerCase();
|
|
const driverHit = driver.includes(q);
|
|
const subnets = fmtSubnets(n.meta).join(' ').toLowerCase();
|
|
const subnetHit = subnets.includes(q);
|
|
return nameHit || driverHit || subnetHit;
|
|
});
|
|
}
|
|
|
|
const sort = state.filters.sort || 'name_asc';
|
|
if (sort === 'containers_desc') {
|
|
out.sort((a, b) => (b.containerCount - a.containerCount) || a.name.localeCompare(b.name));
|
|
} else if (sort === 'driver_asc') {
|
|
out.sort((a, b) => String(a.meta?.driver || '').localeCompare(String(b.meta?.driver || '')) || a.name.localeCompare(b.name));
|
|
} else {
|
|
out.sort((a, b) => a.name.localeCompare(b.name));
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
function _ellipsize(s, n) {
|
|
s = String(s ?? '');
|
|
if (s.length <= n) return s;
|
|
return s.slice(0, Math.max(0, n - 1)) + '…';
|
|
}
|
|
|
|
function buildGlobalGraphModel() {
|
|
// We nemen exact dezelfde selectie als de tabel.
|
|
// 1) Bouw dezelfde rows als tabel
|
|
const vm = buildNetworksViewModel();
|
|
const rows = applyFiltersAndSort(vm);
|
|
|
|
const usage = state.usage || {};
|
|
const byNetwork = usage.byNetwork || {};
|
|
const byContainerMeta = usage.byContainerMeta || {};
|
|
|
|
const nodes = [];
|
|
const links = [];
|
|
|
|
const nodeIndex = new Map(); // key -> nodeId
|
|
const idByName = new Map(); // "mvp-webui" -> "<id>"
|
|
const nameById = new Map(); // "<id>" -> "mvp-webui"
|
|
|
|
function addNode(key, node) {
|
|
if (nodeIndex.has(key)) return nodeIndex.get(key);
|
|
node.id = key; // stabiel ID
|
|
nodes.push(node);
|
|
nodeIndex.set(key, key);
|
|
return key;
|
|
}
|
|
|
|
// Network nodes: alleen netwerken die in de huidige tabel-selectie zitten
|
|
for (const r of rows) {
|
|
const nkey = `net:${r.name}`;
|
|
addNode(nkey, {
|
|
type: 'network',
|
|
key: r.name,
|
|
label: _ellipsize(r.name, 22),
|
|
meta: r.meta || {},
|
|
usageCount: Number(r.containerCount || 0),
|
|
flags: {
|
|
internal: !!r.meta?.internal,
|
|
dnsEnabled: !!r.meta?.dnsEnabled,
|
|
ipv6Enabled: !!r.meta?.ipv6Enabled,
|
|
isDefault: !!r.meta?.isDefault,
|
|
driver: r.meta?.driver || ''
|
|
}
|
|
});
|
|
|
|
// Container links + container nodes vanuit usage.byNetwork
|
|
const netUsage = byNetwork[r.name];
|
|
const ctrs = (netUsage && Array.isArray(netUsage.containers)) ? netUsage.containers : [];
|
|
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), 22),
|
|
name: c.name || '',
|
|
pod: c.pod || '',
|
|
networkMode: c.networkMode || '',
|
|
});
|
|
|
|
if (c.name) idByName.set(String(c.name), String(c.id));
|
|
nameById.set(String(c.id), String(c.name || ''));
|
|
|
|
links.push({
|
|
type: 'attach',
|
|
source: nkey,
|
|
target: ckey
|
|
});
|
|
}
|
|
}
|
|
|
|
// Optionele shared-netns links (stippellijn) op basis van byContainerMeta.networkMode=container:<id>
|
|
// We maken alleen links als beide kanten in het huidige model zitten (licht + consistent met filter)
|
|
for (const [ctrId, cm] of Object.entries(byContainerMeta)) {
|
|
const rawKey = String(ctrId);
|
|
const mode = String(cm?.networkMode || '');
|
|
if (!mode.startsWith('container:')) continue;
|
|
|
|
const ownerId = mode.split(':', 2)[1] || '';
|
|
if (!ownerId) continue;
|
|
|
|
// byContainerMeta kan keys hebben als NAME of ID → normaliseer naar ID als we kunnen
|
|
const childId = idByName.get(rawKey) || rawKey;
|
|
const ownerKey = `ctr:${ownerId}`;
|
|
const childKey = `ctr:${childId}`;
|
|
|
|
if (nodeIndex.has(ownerKey) && nodeIndex.has(childKey)) {
|
|
links.push({
|
|
type: 'shared',
|
|
source: ownerKey,
|
|
target: childKey,
|
|
ownerName: cm?.networkOwnerName || '',
|
|
ownerId: cm?.networkOwnerId || ''
|
|
});
|
|
}
|
|
}
|
|
|
|
// Kleine meta voor debug / later
|
|
const meta = {
|
|
view: 'global',
|
|
networksSelected: rows.length,
|
|
nodes: nodes.length,
|
|
links: links.length
|
|
};
|
|
|
|
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, opts = {}) {
|
|
const host = document.getElementById('networksMapHost');
|
|
if (!host) return;
|
|
|
|
// leeg host (placeholder weg)
|
|
host.innerHTML = '';
|
|
|
|
const w = Math.max(600, host.clientWidth || 600);
|
|
const h = Math.max(420, host.clientHeight || 420);
|
|
|
|
const svg = d3.select(host).append('svg')
|
|
.attr('viewBox', `0 0 ${w} ${h}`);
|
|
|
|
const g = svg.append('g');
|
|
|
|
// zoom/pan
|
|
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')
|
|
.selectAll('line')
|
|
.data(model.links)
|
|
.enter()
|
|
.append('line')
|
|
.attr('class', d => d.type === 'shared' ? 'graphLink shared' : 'graphLink');
|
|
|
|
// nodes
|
|
const node = g.append('g')
|
|
.selectAll('g')
|
|
.data(model.nodes)
|
|
.enter()
|
|
.append('g')
|
|
.attr('class', d => `graphNode ${d.type}`);
|
|
|
|
node.append('circle')
|
|
.attr('r', d => d.type === 'network' ? 10 : 8);
|
|
|
|
node.append('text')
|
|
.attr('class', 'graphLabel')
|
|
.attr('x', 12)
|
|
.attr('y', 4)
|
|
.text(d => d.label || d.key);
|
|
|
|
// drag
|
|
const drag = d3.drag()
|
|
.on('start', (ev, d) => {
|
|
if (!ev.active) graphCtx.sim.alphaTarget(0.2).restart();
|
|
d.fx = d.x; d.fy = d.y;
|
|
})
|
|
.on('drag', (ev, d) => {
|
|
d.fx = ev.x; d.fy = ev.y;
|
|
})
|
|
.on('end', (ev, d) => {
|
|
if (!ev.active) graphCtx.sim.alphaTarget(0);
|
|
d.fx = null; d.fy = null;
|
|
});
|
|
|
|
node.call(drag);
|
|
|
|
// hover highlight (connected)
|
|
node.on('mouseenter', (ev, d) => {
|
|
node.classed('graphDim', true);
|
|
link.classed('graphDim', true);
|
|
|
|
d3.select(ev.currentTarget).classed('graphDim', false);
|
|
|
|
link.each(function(l) {
|
|
const sid = l.source?.id || l.source;
|
|
const tid = l.target?.id || l.target;
|
|
const hit = (sid === d.id || tid === d.id);
|
|
if (hit) {
|
|
d3.select(this).classed('graphDim', false).classed('graphActive', true);
|
|
} else {
|
|
d3.select(this).classed('graphActive', false);
|
|
}
|
|
});
|
|
});
|
|
|
|
node.on('mouseleave', () => {
|
|
node.classed('graphDim', false);
|
|
link.classed('graphDim', false).classed('graphActive', false);
|
|
});
|
|
|
|
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(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
|
|
.attr('x1', d => d.source.x)
|
|
.attr('y1', d => d.source.y)
|
|
.attr('x2', d => d.target.x)
|
|
.attr('y2', d => d.target.y);
|
|
|
|
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;
|
|
|
|
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');
|
|
const rel = document.getElementById('networksRelations');
|
|
if (!tbody || !rel) return;
|
|
|
|
tbody.innerHTML = '';
|
|
rel.innerHTML = '';
|
|
|
|
const usage = state.usage;
|
|
if (!usage || !usage.byNetwork) {
|
|
tbody.innerHTML = `<tr><td colspan="6" class="muted">Geen data. Klik op Vernieuwen.</td></tr>`;
|
|
rel.innerHTML = `<div class="muted">Geen data.</div>`;
|
|
return;
|
|
}
|
|
|
|
const vmAll = buildNetworksViewModel();
|
|
const vm = applyFiltersAndSort(vmAll);
|
|
|
|
for (const row of vm) {
|
|
const netName = row.name;
|
|
const containers = row.containers;
|
|
const meta = row.meta;
|
|
|
|
const isOpen = state.expanded.has(netName);
|
|
const arrow = isOpen ? '▾' : '▸';
|
|
|
|
const driver = meta?.driver || meta?.Driver || '';
|
|
const subnets = fmtSubnets(meta);
|
|
const subnetsHtml = subnets.length
|
|
? `<div style="display:flex; flex-direction:column; gap:2px;">${subnets.slice(0, 2).map(s => `<span class="muted">${esc(s)}</span>`).join('')}${subnets.length > 2 ? `<span class="muted">+${subnets.length - 2}</span>` : ''}</div>`
|
|
: (
|
|
(meta?.driver === 'mode')
|
|
? `<span class="muted">network mode</span>`
|
|
: `<span class="muted">—</span>`
|
|
);
|
|
|
|
// 1. Controleer of dit een shared netwerk is
|
|
const isShared = containers.some(c => typeof c?.networkMode === 'string' && c.networkMode.startsWith('container:'));
|
|
|
|
const tr = document.createElement('tr');
|
|
tr.innerHTML = `
|
|
<td><button class="btn small ghost" title="Open/dicht">${arrow}</button></td>
|
|
<td>
|
|
<div style="display:flex; align-items:center; gap:8px;">
|
|
<span>${esc(netName)}</span>
|
|
${isDefaultNetworkName(netName) ? `<span class="badge">default</span>` : ``}
|
|
</div>
|
|
</td>
|
|
<td>${driver
|
|
? (String(driver) === 'mode' ? `<span class="badge">MODE</span>` : esc(driver))
|
|
: `<span class="muted">—</span>`}
|
|
</td>
|
|
<td>${subnetsHtml}</td>
|
|
|
|
<td style="text-align:right;">
|
|
<span>${containers.length}</span>
|
|
</td>
|
|
|
|
<td>
|
|
<div class="row gap" style="flex-wrap:wrap;">
|
|
${isShared ? `<span class="badge">shared</span>` : ``}
|
|
${flagsHTML(netName, meta)}
|
|
</div>
|
|
</td>
|
|
`;
|
|
|
|
tr.querySelector('button').addEventListener('click', () => toggleNetworkRow(netName));
|
|
tbody.appendChild(tr);
|
|
|
|
if (isOpen) {
|
|
const tr2 = document.createElement('tr');
|
|
tr2.innerHTML = `
|
|
<td></td>
|
|
<td colspan="5">
|
|
<div style="padding:8px 0;">
|
|
${renderNetworkUsersListHTML(netName, containers)}
|
|
<div class="row gap" style="margin-top:8px;">
|
|
<button class="btn small" data-inspect="${esc(netName)}">Inspect details</button>
|
|
<span class="muted" id="inspectStatus-${cssId(netName)}"></span>
|
|
</div>
|
|
<pre class="code" id="inspectBox-${cssId(netName)}" style="display:none; margin-top:8px; max-height:260px; overflow:auto;"></pre>
|
|
</div>
|
|
</td>
|
|
`;
|
|
tbody.appendChild(tr2);
|
|
|
|
const btn = tr2.querySelector('button[data-inspect]');
|
|
btn.addEventListener('click', async () => {
|
|
await onInspectNetwork(netName);
|
|
});
|
|
}
|
|
}
|
|
|
|
rel.innerHTML = renderNetworksRelationsHTML(usage);
|
|
}
|
|
|
|
function bindUiOnce() {
|
|
const btn = document.getElementById('networksRefreshBtn');
|
|
if (btn && !btn.dataset.bound) {
|
|
btn.dataset.bound = '1';
|
|
btn.addEventListener('click', refresh);
|
|
}
|
|
|
|
// View toggle (Tabel/Kaart)
|
|
const viewToggle = document.getElementById('networksViewToggle');
|
|
if (viewToggle && !viewToggle.dataset.bound) {
|
|
viewToggle.dataset.bound = '1';
|
|
viewToggle.addEventListener('click', (ev) => {
|
|
const t = ev.target;
|
|
if (!(t instanceof HTMLElement)) return;
|
|
const b = t.closest('button[data-view]');
|
|
if (!b) return;
|
|
setNetworksView(b.getAttribute('data-view'));
|
|
});
|
|
}
|
|
|
|
// Map toolbar placeholders (no-op for now)
|
|
const legendBtn = document.getElementById('networksMapLegendBtn');
|
|
const legend = document.getElementById('networksMapLegend');
|
|
if (legendBtn && !legendBtn.dataset.bound) {
|
|
legendBtn.dataset.bound = '1';
|
|
legendBtn.addEventListener('click', () => {
|
|
if (!legend) return;
|
|
legend.style.display = (legend.style.display === 'none' || !legend.style.display) ? '' : 'none';
|
|
});
|
|
}
|
|
|
|
const resetBtn = document.getElementById('networksMapResetBtn');
|
|
if (resetBtn && !resetBtn.dataset.bound) {
|
|
resetBtn.dataset.bound = '1';
|
|
resetBtn.addEventListener('click', () => {
|
|
if (!graphCtx) return;
|
|
|
|
// reset zoom
|
|
graphCtx.svg
|
|
.transition()
|
|
.duration(400)
|
|
.call(graphCtx.zoom.transform, d3.zoomIdentity);
|
|
|
|
// restart simulation
|
|
graphCtx.model.nodes.forEach(n => { n.fx = null; n.fy = null; });
|
|
graphCtx.sim.alpha(1).restart();
|
|
});
|
|
}
|
|
|
|
const layoutBtn = document.getElementById('networksMapLayoutBtn');
|
|
if (layoutBtn && !layoutBtn.dataset.bound) {
|
|
layoutBtn.dataset.bound = '1';
|
|
layoutBtn.addEventListener('click', () => {
|
|
if (!graphCtx) return;
|
|
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)}…`;
|
|
});
|
|
}
|
|
|
|
const search = document.getElementById('networksSearch');
|
|
const fConnected = document.getElementById('networksFilterConnected');
|
|
const fHideDefaults = document.getElementById('networksFilterHideDefaults');
|
|
const fShared = document.getElementById('networksFilterShared');
|
|
const sort = document.getElementById('networksSort');
|
|
|
|
function rerender() {
|
|
renderNetworks();
|
|
if (state.view === 'map') {
|
|
const model = buildGlobalGraphModel();
|
|
const s = document.getElementById('networksMapStatus');
|
|
if (s) s.textContent = `Kaart: ${model.meta.nodes} nodes, ${model.meta.links} links`;
|
|
renderGraph(model);
|
|
}
|
|
}
|
|
|
|
if (search && !search.dataset.bound) {
|
|
search.dataset.bound = '1';
|
|
search.addEventListener('input', () => {
|
|
state.filters.q = search.value || '';
|
|
rerender();
|
|
});
|
|
}
|
|
|
|
if (fConnected && !fConnected.dataset.bound) {
|
|
fConnected.dataset.bound = '1';
|
|
fConnected.addEventListener('change', () => {
|
|
state.filters.connectedOnly = !!fConnected.checked;
|
|
rerender();
|
|
});
|
|
}
|
|
|
|
if (fHideDefaults && !fHideDefaults.dataset.bound) {
|
|
fHideDefaults.dataset.bound = '1';
|
|
state.filters.hideDefaults = !!fHideDefaults.checked;
|
|
fHideDefaults.addEventListener('change', () => {
|
|
state.filters.hideDefaults = !!fHideDefaults.checked;
|
|
rerender();
|
|
});
|
|
}
|
|
|
|
if (fShared && !fShared.dataset.bound) {
|
|
fShared.dataset.bound = '1';
|
|
fShared.addEventListener('change', () => {
|
|
state.filters.sharedOnly = !!fShared.checked;
|
|
rerender();
|
|
});
|
|
}
|
|
|
|
if (sort && !sort.dataset.bound) {
|
|
sort.dataset.bound = '1';
|
|
sort.addEventListener('change', () => {
|
|
state.filters.sort = sort.value || 'name_asc';
|
|
rerender();
|
|
});
|
|
}
|
|
|
|
renderNetworksSummary();
|
|
setNetworksView(state.view);
|
|
}
|
|
|
|
// Expose minimal API
|
|
window.mvpNetworks = {
|
|
refresh,
|
|
state,
|
|
bindUiOnce,
|
|
debugGraph: () => {
|
|
const model = buildGlobalGraphModel();
|
|
console.log('[networks] graph meta', model.meta);
|
|
console.table(model.nodes.slice(0, 12));
|
|
console.table(model.links.slice(0, 12));
|
|
return model;
|
|
},
|
|
};
|
|
|
|
// Bind when script loads (DOM is already mostly there because script is at end of body)
|
|
bindUiOnce();
|
|
})(); |