From 18ee367e1d631035172455a6d988732ec2b7b312 Mon Sep 17 00:00:00 2001 From: kodi Date: Sun, 22 Feb 2026 18:24:57 +0100 Subject: [PATCH] feat(gui): netwerk tab netwerk layout grafisch --- collect_code.py | 38 +++++ webui/html/assets/css/app.css | 86 ++++++++++- webui/html/assets/js/tabs/networks.js | 206 +++++++++++++++++++++++++- webui/html/index.html | 33 ++++- 4 files changed, 360 insertions(+), 3 deletions(-) create mode 100644 collect_code.py diff --git a/collect_code.py b/collect_code.py new file mode 100644 index 0000000..6e358e5 --- /dev/null +++ b/collect_code.py @@ -0,0 +1,38 @@ +import os + +# Bestanden of mappen die we NIET willen zien +EXCLUDE_DIRS = {'.git', 'node_modules', '__pycache__', 'venv', '.next', 'dist', 'build'} +EXCLUDE_FILES = {'collect_code.py', 'project_context.txt', 'package-lock.json', '.DS_Store'} +# Welke bestandstypes we wel willen verzamelen +INCLUDE_EXTENSIONS = {'.js', '.jsx', '.ts', '.tsx', '.py', '.html', '.css', '.json'} + +def collect_code(): + output_file = "project_context.txt" + + with open(output_file, "w", encoding="utf-8") as f: + for root, dirs, files in os.walk("."): + # Filter uitgesloten mappen + dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS] + + for file in files: + if file in EXCLUDE_FILES: + continue + + ext = os.path.splitext(file)[1] + if ext in INCLUDE_EXTENSIONS: + full_path = os.path.join(root, file) + f.write(f"\n{'='*50}\n") + f.write(f"FILE: {full_path}\n") + f.write(f"{'='*50}\n\n") + + try: + with open(full_path, "r", encoding="utf-8") as code_file: + f.write(code_file.read()) + except Exception as e: + f.write(f"Fout bij lezen bestand: {e}") + f.write("\n") + + print(f"Klaar! Alle code staat in {output_file}") + +if __name__ == "__main__": + collect_code() diff --git a/webui/html/assets/css/app.css b/webui/html/assets/css/app.css index a26e7ad..7b18129 100644 --- a/webui/html/assets/css/app.css +++ b/webui/html/assets/css/app.css @@ -487,4 +487,88 @@ pre{ font-size: 11px; margin-left: 4px; opacity: .75; -} \ No newline at end of file +} +/* ===== Netwerken: Tabel/Kaart toggle + kaart placeholders (STAP 3A-1) ===== */ +.segToggle{ + display:inline-flex; + border:1px solid rgba(255,255,255,0.12); + background: rgba(255,255,255,0.04); + border-radius: 12px; + overflow:hidden; +} + +.segToggle .seg{ + appearance:none; + border:0; + background: transparent; + color: inherit; + padding: 8px 10px; + font-size: 13px; + line-height: 1; + cursor:pointer; + opacity: .85; +} + +.segToggle .seg:hover{ opacity: 1; } + +.segToggle .seg.active{ + background: rgba(255,255,255,0.08); + opacity: 1; +} + +.mapHost{ + border: 1px solid rgba(255,255,255,0.10); + background: rgba(0,0,0,0.18); + border-radius: 14px; + min-height: 420px; + overflow: hidden; +} + +.mapLegend{ + 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; + max-width: 360px; +} + +.mapLegend .legendTitle{ + font-weight: 700; + margin-bottom: 8px; +} + +.mapLegend .legendRow{ + display:flex; + align-items:center; + gap: 10px; + margin: 6px 0; +} + +.mapLegend .legendSwatch{ + width: 14px; + height: 14px; + border-radius: 6px; + display:inline-block; + border: 1px solid rgba(255,255,255,0.14); +} + +.mapLegend .legendSwatch.link{ + width: 18px; + height: 2px; + border-radius: 2px; + border: 0; + background: rgba(255,255,255,0.22); +} + +.mapLegend .legendSwatch.shared{ + width: 18px; + height: 2px; + border-radius: 0; + border: 0; + background: rgba(255,255,255,0.22); + background-image: repeating-linear-gradient(90deg, rgba(255,255,255,0.22), rgba(255,255,255,0.22) 6px, transparent 6px, transparent 10px); +} + +.mapLegend .legendSwatch.net{ background: rgba(80,160,255,0.35); } +.mapLegend .legendSwatch.ctr{ background: rgba(150,230,150,0.30); } \ 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 833f3d5..b639f8b 100644 --- a/webui/html/assets/js/tabs/networks.js +++ b/webui/html/assets/js/tabs/networks.js @@ -40,6 +40,7 @@ usage: null, list: null, inspectCache: new Map(), + view: 'table', // 'table' | 'map' filters: { q: '', connectedOnly: false, @@ -48,6 +49,39 @@ 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') { + try { + const model = buildGlobalGraphModel(); + const s = document.getElementById('networksMapStatus'); + if (s) s.textContent = `Kaart: ${model.meta.nodes} nodes, ${model.meta.links} links`; + } catch (e) { + console.error('[networks] buildGlobalGraphModel failed', e); + } + } + + } function toggleNetworkRow(name) { if (state.expanded.has(name)) state.expanded.delete(name); @@ -266,7 +300,7 @@ if (d !== 'mode') { badges.push(`${esc(d)}`); } -} + } return badges.join(' '); } @@ -381,6 +415,117 @@ 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" -> "" + const nameById = new Map(); // "" -> "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: + // 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 renderNetworks() { const tbody = document.getElementById('networksTbody'); @@ -485,6 +630,52 @@ 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', () => { + // In 3C koppelen we dit aan zoom reset + simulation reset + const s = document.getElementById('networksMapStatus'); + if (s) s.textContent = 'Reset view (placeholder)'; + setTimeout(() => { if (s) s.textContent = 'Kaartweergave (placeholder)'; }, 900); + }); + } + + const layoutBtn = document.getElementById('networksMapLayoutBtn'); + if (layoutBtn && !layoutBtn.dataset.bound) { + layoutBtn.dataset.bound = '1'; + layoutBtn.addEventListener('click', () => { + // In 3C koppelen we dit aan simulation.restart() + const s = document.getElementById('networksMapStatus'); + if (s) s.textContent = 'Auto-layout (placeholder)'; + setTimeout(() => { if (s) s.textContent = 'Kaartweergave (placeholder)'; }, 900); + }); + } + const search = document.getElementById('networksSearch'); const fConnected = document.getElementById('networksFilterConnected'); const fHideDefaults = document.getElementById('networksFilterHideDefaults'); @@ -493,6 +684,11 @@ 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`; + } } if (search && !search.dataset.bound) { @@ -537,6 +733,7 @@ } renderNetworksSummary(); + setNetworksView(state.view); } // Expose minimal API @@ -544,6 +741,13 @@ 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) diff --git a/webui/html/index.html b/webui/html/index.html index aeefbc3..9420417 100644 --- a/webui/html/index.html +++ b/webui/html/index.html @@ -128,6 +128,11 @@
+
+ + +
+
+ + @@ -166,7 +197,7 @@
-
+

Relaties