From 565a7b6ab3597e4b8e92c8117493d65d202bdba7 Mon Sep 17 00:00:00 2001 From: Jukka Lampikoski Date: Wed, 11 Mar 2026 09:13:53 +0200 Subject: [PATCH] =?UTF-8?q?IPAM:=20Hierarkkinen=20puun=C3=A4kym=C3=A4=20+?= =?UTF-8?q?=20VLANit=20erilleen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Subnetit ja IP:t näytetään hierarkkisena puuna jossa pienemmät verkot menevät isomman sisään (esim. /24 → /16 → /8) - Expand/collapse klikkaamalla ▶/▼ ikonia - Drill-down: poraudu verkon sisään → -nuolella, breadcrumb ylhäällä - VLANit erotettu omaksi luetteloksi verkkojen alapuolelle - Omat "Lisää verkko/IP" ja "Lisää VLAN" -napit Co-Authored-By: Claude Opus 4.6 --- index.html | 42 ++++++++-- script.js | 238 ++++++++++++++++++++++++++++++++++++++++++++++++++--- style.css | 17 ++++ 3 files changed, 278 insertions(+), 19 deletions(-) diff --git a/index.html b/index.html index 24b3a1b..8661424 100644 --- a/index.html +++ b/index.html @@ -271,17 +271,18 @@
+
- +
- + - + @@ -291,11 +292,38 @@
TyyppiTyyppi Verkko / IPVLAN Nimi / KuvausVLAN Sijainti Tila Asiakas
- 0 merkintää + 0 verkkoa +
+ + +
+
+

VLANit

+ +
+
+ + + + + + + + + + + + + +
VLANVerkkoNimi / KuvausSijaintiTilaAsiakasToiminnot
+
+
+ 0 VLANia +
diff --git a/script.js b/script.js index 5b1dd75..98c3852 100644 --- a/script.js +++ b/script.js @@ -2957,6 +2957,102 @@ document.getElementById('btn-cancel-site')?.addEventListener('click', () => { // ==================== IPAM ==================== let ipamData = []; +let ipamExpandedIds = new Set(); +let ipamDrillStack = []; // [{id, label}] breadcrumb + +// --- IP-laskenta-apufunktiot --- +function ipToInt(ip) { + return ip.split('.').reduce((acc, oct) => (acc << 8) + parseInt(oct), 0) >>> 0; +} +function prefixToMask(prefix) { + return prefix === 0 ? 0 : (~0 << (32 - prefix)) >>> 0; +} +function parseNetwork(verkko) { + if (!verkko) return null; + const v = verkko.trim(); + if (v.includes('/')) { + const [ip, pfx] = v.split('/'); + const prefix = parseInt(pfx); + if (isNaN(prefix) || prefix < 0 || prefix > 32) return null; + const parts = ip.split('.'); + if (parts.length !== 4) return null; + return { net: ipToInt(ip), prefix }; + } else { + const parts = v.split('.'); + if (parts.length !== 4) return null; + return { net: ipToInt(v), prefix: 32 }; + } +} +function isSubnetOf(childNet, childPrefix, parentNet, parentPrefix) { + if (childPrefix <= parentPrefix) return false; + const mask = prefixToMask(parentPrefix); + return (childNet & mask) === (parentNet & mask); +} + +// --- Puurakenne --- +function buildIpamTree(entries) { + // Parsitaan verkko-osoitteet ja suodatetaan validit + const items = entries.map(e => { + const parsed = parseNetwork(e.verkko); + return parsed ? { entry: e, net: parsed.net, prefix: parsed.prefix, children: [] } : null; + }).filter(Boolean); + // Järjestetään: pienin prefix (isoin verkko) ensin, sitten IP-osoitteen mukaan + items.sort((a, b) => a.prefix - b.prefix || a.net - b.net); + const roots = []; + for (const item of items) { + let placed = false; + // Etsi lähin parent (suurin prefix joka sisältää tämän) + const findParent = (nodes) => { + for (let i = nodes.length - 1; i >= 0; i--) { + const node = nodes[i]; + if (isSubnetOf(item.net, item.prefix, node.net, node.prefix)) { + // Tarkista ensin onko jokin lapsi tarkempi parent + if (!findParent(node.children)) { + node.children.push(item); + } + return true; + } + } + return false; + }; + if (!findParent(roots)) { + roots.push(item); + } + } + return roots; +} + +function flattenTree(nodes, depth, drillId) { + // Jos drill-down aktiivinen, etsi drill-node ja renderöi vain sen lapset + if (drillId) { + const findNode = (list) => { + for (const n of list) { + if (n.entry.id === drillId) return n; + const found = findNode(n.children); + if (found) return found; + } + return null; + }; + const drillNode = findNode(nodes); + if (drillNode) { + nodes = drillNode.children; + depth = 0; + } + } + const rows = []; + const render = (list, d) => { + for (const node of list) { + const hasChildren = node.children.length > 0; + const expanded = ipamExpandedIds.has(node.entry.id); + rows.push({ entry: node.entry, depth: d, hasChildren, expanded }); + if (hasChildren && expanded) { + render(node.children, d + 1); + } + } + }; + render(nodes, depth); + return rows; +} async function loadIpam() { try { @@ -2967,9 +3063,11 @@ async function loadIpam() { function renderIpam() { const query = (document.getElementById('ipam-search-input')?.value || '').toLowerCase().trim(); - let filtered = ipamData; + + // --- Verkot + IP:t (hierarkkinen puu) --- + let networkEntries = ipamData.filter(e => e.tyyppi === 'subnet' || e.tyyppi === 'ip'); if (query) { - filtered = ipamData.filter(e => + networkEntries = networkEntries.filter(e => (e.tyyppi || '').toLowerCase().includes(query) || (e.verkko || '').toLowerCase().includes(query) || (e.nimi || '').toLowerCase().includes(query) || @@ -2979,22 +3077,107 @@ function renderIpam() { String(e.vlan_id || '').includes(query) ); } + + const tree = buildIpamTree(networkEntries); + + // Breadcrumb + const bcEl = document.getElementById('ipam-breadcrumb'); + if (bcEl) { + const drillId = ipamDrillStack.length > 0 ? ipamDrillStack[ipamDrillStack.length - 1].id : null; + if (ipamDrillStack.length === 0) { + bcEl.style.display = 'none'; + } else { + bcEl.style.display = ''; + bcEl.innerHTML = `Kaikki verkot` + + ipamDrillStack.map((s, i) => + ` ${esc(s.label)}` + ).join(''); + } + // Flatten tree siten että drill-down huomioidaan + var drillTarget = drillId; + } else { + var drillTarget = null; + } + + const rows = flattenTree(tree, 0, drillTarget); + const tbody = document.getElementById('ipam-tbody'); const noIpam = document.getElementById('no-ipam'); - if (filtered.length === 0) { + const tilaClass = { vapaa: 'ipam-tila-vapaa', varattu: 'ipam-tila-varattu', reserved: 'ipam-tila-reserved' }; + const tilaLabel = { vapaa: 'Vapaa', varattu: 'Varattu', reserved: 'Reserved' }; + + if (rows.length === 0 && !query) { tbody.innerHTML = ''; if (noIpam) noIpam.style.display = 'block'; } else { if (noIpam) noIpam.style.display = 'none'; - const tyyppiLabel = { subnet: 'Subnet', vlan: 'VLAN', ip: 'IP' }; - const tilaClass = { vapaa: 'ipam-tila-vapaa', varattu: 'ipam-tila-varattu', reserved: 'ipam-tila-reserved' }; - const tilaLabel = { vapaa: 'Vapaa', varattu: 'Varattu', reserved: 'Reserved' }; - tbody.innerHTML = filtered.map(e => ` - ${tyyppiLabel[e.tyyppi] || e.tyyppi} + tbody.innerHTML = rows.map(r => { + const e = r.entry; + const indent = r.depth * 1.5; + const toggleIcon = r.hasChildren + ? `${r.expanded ? '▼' : '▶'} ` + : ' '; + const typeTag = e.tyyppi === 'subnet' + ? 'Subnet' + : 'IP'; + const drillBtn = (e.tyyppi === 'subnet' && r.hasChildren) + ? ` ` + : ''; + return ` + + ${toggleIcon}${typeTag} + + ${esc(e.verkko || '-')}${drillBtn} + ${esc(e.nimi || '-')} + ${e.vlan_id ? '' + e.vlan_id + '' : '-'} + ${e.site_name ? esc(e.site_name) : ''} + ${tilaLabel[e.tila] || e.tila} + ${esc(e.asiakas || '-')} + + + + + `; + }).join(''); + } + + const netCount = networkEntries.length; + document.getElementById('ipam-count').textContent = netCount + ' verkkoa/IP:tä' + (query ? ` (${ipamData.filter(e => e.tyyppi !== 'vlan').length} yhteensä)` : ''); + + // --- VLANit --- + renderIpamVlans(query); +} + +function renderIpamVlans(query) { + let vlans = ipamData.filter(e => e.tyyppi === 'vlan'); + if (query) { + vlans = vlans.filter(e => + String(e.vlan_id || '').includes(query) || + (e.nimi || '').toLowerCase().includes(query) || + (e.verkko || '').toLowerCase().includes(query) || + (e.site_name || '').toLowerCase().includes(query) || + (e.asiakas || '').toLowerCase().includes(query) + ); + } + vlans.sort((a, b) => (a.vlan_id || 0) - (b.vlan_id || 0)); + + const tbody = document.getElementById('ipam-vlan-tbody'); + const section = document.getElementById('ipam-vlan-section'); + if (!tbody) return; + + const tilaClass = { vapaa: 'ipam-tila-vapaa', varattu: 'ipam-tila-varattu', reserved: 'ipam-tila-reserved' }; + const tilaLabel = { vapaa: 'Vapaa', varattu: 'Varattu', reserved: 'Reserved' }; + + if (vlans.length === 0 && !query) { + tbody.innerHTML = ''; + if (section) section.style.display = 'none'; + } else { + if (section) section.style.display = ''; + tbody.innerHTML = vlans.map(e => ` + ${e.vlan_id || '-'} ${esc(e.verkko || '-')} - ${e.vlan_id ? '' + e.vlan_id + '' : '-'} ${esc(e.nimi || '-')} - ${e.site_name ? esc(e.site_name) : '-'} + ${e.site_name ? esc(e.site_name) : ''} ${tilaLabel[e.tila] || e.tila} ${esc(e.asiakas || '-')} @@ -3003,7 +3186,28 @@ function renderIpam() { `).join(''); } - document.getElementById('ipam-count').textContent = filtered.length + ' merkintää' + (query ? ` (${ipamData.length} yhteensä)` : ''); + document.getElementById('ipam-vlan-count').textContent = vlans.length + ' VLANia'; +} + +// --- Toggle & Drill --- +function ipamToggle(id) { + if (ipamExpandedIds.has(id)) ipamExpandedIds.delete(id); + else ipamExpandedIds.add(id); + renderIpam(); +} +function ipamDrillInto(id, label) { + ipamDrillStack.push({ id, label }); + ipamExpandedIds.clear(); // reset expand-tila uudessa näkymässä + renderIpam(); +} +function ipamDrillTo(index) { + if (index < 0) { + ipamDrillStack = []; + } else { + ipamDrillStack = ipamDrillStack.slice(0, index + 1); + } + ipamExpandedIds.clear(); + renderIpam(); } async function loadIpamSitesDropdown() { @@ -3043,8 +3247,18 @@ async function deleteIpam(id, name) { document.getElementById('btn-add-ipam')?.addEventListener('click', async () => { document.getElementById('ipam-form-id').value = ''; document.getElementById('ipam-form').reset(); + document.getElementById('ipam-form-tyyppi').value = 'subnet'; await loadIpamSitesDropdown(); - document.getElementById('ipam-modal-title').textContent = 'Lisää IPAM-merkintä'; + document.getElementById('ipam-modal-title').textContent = 'Lisää verkko / IP'; + document.getElementById('ipam-modal').style.display = 'flex'; +}); + +document.getElementById('btn-add-vlan')?.addEventListener('click', async () => { + document.getElementById('ipam-form-id').value = ''; + document.getElementById('ipam-form').reset(); + document.getElementById('ipam-form-tyyppi').value = 'vlan'; + await loadIpamSitesDropdown(); + document.getElementById('ipam-modal-title').textContent = 'Lisää VLAN'; document.getElementById('ipam-modal').style.display = 'flex'; }); diff --git a/style.css b/style.css index e5b8abd..9e7f6fa 100644 --- a/style.css +++ b/style.css @@ -1073,6 +1073,23 @@ span.empty { .ipam-tila-varattu { background:#fef3cd; color:#856404; } .ipam-tila-reserved { background:#e2e3e5; color:#495057; } +/* IPAM puunäkymä */ +.ipam-tree-table tbody tr { cursor: pointer; } +.ipam-tree-table tbody tr:hover { background: #f0f4ff; } +.ipam-toggle { cursor:pointer; display:inline-block; width:1rem; text-align:center; font-size:0.7rem; color:#555; user-select:none; } +.ipam-toggle:hover { color:#0f3460; } +.ipam-toggle-placeholder { display:inline-block; width:1rem; } +.ipam-type-subnet { display:inline-block; padding:1px 8px; border-radius:4px; font-size:0.75rem; font-weight:700; background:#e0e7ff; color:#3730a3; } +.ipam-type-ip { display:inline-block; padding:1px 8px; border-radius:4px; font-size:0.75rem; font-weight:700; background:#f0fdf4; color:#166534; } +.ipam-network { font-size:0.85rem; background:#f8f9fa; padding:1px 6px; border-radius:3px; } +.ipam-drill { cursor:pointer; font-size:0.8rem; color:#6366f1; margin-left:0.4rem; } +.ipam-drill:hover { color:#4338ca; text-decoration:underline; } +.ipam-breadcrumb { padding:0.5rem 0; font-size:0.85rem; color:#555; } +.ipam-bc-link { cursor:pointer; color:#6366f1; } +.ipam-bc-link:hover { text-decoration:underline; } +.ipam-bc-current { color:#333; font-weight:600; cursor:default; } +.ipam-bc-current:hover { text-decoration:none; } + /* Role badge */ .role-badge { display: inline-block;