From 44053d27f24df24375085c52c727f9bf6246c3d4 Mon Sep 17 00:00:00 2001 From: Jukka Lampikoski Date: Wed, 11 Mar 2026 09:50:18 +0200 Subject: [PATCH] =?UTF-8?q?IPAM:=20duplikaatti-IP-tarkistus,=20vapaat=20lo?= =?UTF-8?q?hkot,=20asiakas-kent=C3=A4n=20poisto=20+=20varattu=20oletus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Duplikaatti-IP/verkko -tarkistus: estää saman verkko-osoitteen lisäämisen kahdesti - Vapaan tilan näyttö: kun subnet avataan, näytetään vapaat osoitelohkot lasten välissä (vihreä "Vapaa"-rivi) - Asiakas-kenttä poistettu IPAM-näkymästä (taulukot, lomake, haku) - Varattu oletustilaksi verkkoa/VLANia lisättäessä Co-Authored-By: Claude Opus 4.6 --- api.php | 11 +++++ index.html | 8 ---- script.js | 136 ++++++++++++++++++++++++++++++++++++++++++++--------- style.css | 4 ++ 4 files changed, 128 insertions(+), 31 deletions(-) diff --git a/api.php b/api.php index d7a10c2..06b88d7 100644 --- a/api.php +++ b/api.php @@ -1836,6 +1836,17 @@ switch ($action) { 'muokattu' => date('Y-m-d H:i:s'), 'muokkaaja' => currentUser(), ]; + // Duplikaatti-tarkistus: sama verkko/IP ei saa olla jo olemassa + if ($entry['verkko'] !== '' && $entry['tyyppi'] !== 'vlan') { + $existingAll = dbLoadIpam($companyId); + foreach ($existingAll as $ex) { + if ($ex['verkko'] === $entry['verkko'] && $ex['id'] !== $entry['id'] && $ex['tyyppi'] !== 'vlan') { + http_response_code(400); + echo json_encode(['error' => 'IP-osoite tai verkko "' . $entry['verkko'] . '" on jo olemassa (' . ($ex['nimi'] ?: 'nimetön') . ')']); + exit; + } + } + } dbSaveIpam($companyId, $entry); // Auto-VLAN: jos subnet/ip:llä on vlan_id, luo VLAN automaattisesti jos ei vielä ole if ($entry['tyyppi'] !== 'vlan' && !empty($entry['vlan_id'])) { diff --git a/index.html b/index.html index 2b9a970..06128cc 100644 --- a/index.html +++ b/index.html @@ -285,7 +285,6 @@ VLAN Sijainti Tila - Asiakas Toiminnot @@ -314,7 +313,6 @@ Nimi / Kuvaus Sijainti Tila - Asiakas Toiminnot @@ -1036,12 +1034,6 @@ -
- - -
diff --git a/script.js b/script.js index 26e8293..3f9c32e 100644 --- a/script.js +++ b/script.js @@ -3022,6 +3022,80 @@ function isSubnetOf(childNet, childPrefix, childBits, parentNet, parentPrefix, p const shift = BigInt(parentBits - parentPrefix); return (childNet >> shift) === (parentNet >> shift); } +// BigInt -> IP-osoite merkkijono +function biToIpv4(bi) { + return [Number((bi >> 24n) & 0xFFn), Number((bi >> 16n) & 0xFFn), Number((bi >> 8n) & 0xFFn), Number(bi & 0xFFn)].join('.'); +} +function biToIpv6(bi) { + const parts = []; + for (let i = 7; i >= 0; i--) parts.push(Number((bi >> BigInt(i * 16)) & 0xFFFFn).toString(16)); + // Yksinkertaistettu: ei :: kompressointia + return parts.join(':'); +} +function biToIp(bi, v6) { return v6 ? biToIpv6(bi) : biToIpv4(bi); } + +// Laske vapaat lohkot parent-subnetin sisällä (aukot lasten välissä) +function findFreeSpaces(parentNode, maxEntries = 30) { + if (!parentNode || parentNode.bits === 0 || parentNode.entry.tyyppi !== 'subnet') return []; + const pNet = parentNode.net; + const pPrefix = parentNode.prefix; + const pBits = parentNode.bits; + const hostBits = BigInt(pBits - pPrefix); + const parentStart = (pNet >> hostBits) << hostBits; + const parentSize = 1n << hostBits; + const parentEnd = parentStart + parentSize; + + // Kerää lapset samasta osoiteperheestä, järjestä osoitteen mukaan + const children = parentNode.children + .filter(c => c.bits === pBits) + .sort((a, b) => a.net < b.net ? -1 : a.net > b.net ? 1 : 0); + + const result = []; + let pos = parentStart; + + for (const child of children) { + const cHostBits = BigInt(pBits - child.prefix); + const childStart = (child.net >> cHostBits) << cHostBits; + const childEnd = childStart + (1n << cHostBits); + if (pos < childStart) { + addAlignedBlocks(result, pos, childStart, pBits, pBits === 128, maxEntries - result.length); + } + if (childEnd > pos) pos = childEnd; + } + if (pos < parentEnd) { + addAlignedBlocks(result, pos, parentEnd, pBits, pBits === 128, maxEntries - result.length); + } + return result; +} + +function addAlignedBlocks(result, start, end, totalBits, v6, maxAdd) { + let pos = start; + const tb = BigInt(totalBits); + while (pos < end && maxAdd > 0) { + const space = end - pos; + // Alignment: kuinka monta trailing nollaa pos:ssa + let alignBits = 0n; + if (pos === 0n) { + alignBits = tb; + } else { + let tmp = pos; + while ((tmp & 1n) === 0n && alignBits < tb) { alignBits++; tmp >>= 1n; } + } + // Suurin 2^n joka mahtuu tilaan + let spaceBits = 0n; + let tmp = space >> 1n; + while (tmp > 0n) { spaceBits++; tmp >>= 1n; } + // Tarkista ettei ylitä + if ((1n << spaceBits) > space) spaceBits--; + const blockBits = alignBits < spaceBits ? alignBits : spaceBits; + if (blockBits < 0n) break; + const prefix = totalBits - Number(blockBits); + result.push({ net: pos, prefix, bits: totalBits, v6, verkko: biToIp(pos, v6) + '/' + prefix }); + pos += (1n << blockBits); + maxAdd--; + } +} + // Laske subnetin käyttöaste: kuinka monta lasta (direct children) vs kapasiteetti function subnetUsageHtml(node) { if (node.entry.tyyppi !== 'subnet' || node.children.length === 0) return ''; @@ -3107,9 +3181,30 @@ function flattenTree(nodes, depth, drillId) { 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, node }); + rows.push({ entry: node.entry, depth: d, hasChildren, expanded, node, isFree: false }); if (hasChildren && expanded) { - render(node.children, d + 1); + // Laske vapaat lohkot ja sekoita lasten sekaan osoitejärjestyksessä + const freeSpaces = findFreeSpaces(node); + if (freeSpaces.length > 0) { + // Yhdistä lapset + vapaat, järjestä osoitteen mukaan + const allItems = [ + ...node.children.map(c => ({ type: 'node', item: c, sortKey: c.net })), + ...freeSpaces.map(f => ({ type: 'free', item: f, sortKey: f.net })) + ]; + allItems.sort((a, b) => a.sortKey < b.sortKey ? -1 : a.sortKey > b.sortKey ? 1 : 0); + for (const ai of allItems) { + if (ai.type === 'node') { + render([ai.item], d + 1); + } else { + rows.push({ + entry: { verkko: ai.item.verkko, tyyppi: 'free', nimi: '', tila: 'vapaa' }, + depth: d + 1, hasChildren: false, expanded: false, node: ai.item, isFree: true + }); + } + } + } else { + render(node.children, d + 1); + } } } }; @@ -3135,7 +3230,6 @@ function renderIpam() { (e.verkko || '').toLowerCase().includes(query) || (e.nimi || '').toLowerCase().includes(query) || (e.site_name || '').toLowerCase().includes(query) || - (e.asiakas || '').toLowerCase().includes(query) || (e.lisatiedot || '').toLowerCase().includes(query) || String(e.vlan_id || '').includes(query) ); @@ -3177,6 +3271,18 @@ function renderIpam() { tbody.innerHTML = rows.map(r => { const e = r.entry; const indent = r.depth * 1.5; + + // Vapaa-lohko (ei oikea entry, vaan laskettu vapaa tila) + if (r.isFree) { + return ` + + Vapaa + + ${esc(e.verkko)} + Käytettävissä + `; + } + const toggleIcon = r.hasChildren ? `${r.expanded ? '▼' : '▶'} ` : ' '; @@ -3195,7 +3301,6 @@ function renderIpam() { ${vlanRefHtml(e.vlan_id)} ${e.site_name ? esc(e.site_name) : ''} ${tilaLabel[e.tila] || e.tila} - ${esc(e.asiakas || '-')} @@ -3218,8 +3323,7 @@ function renderIpamVlans(query) { 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) + (e.site_name || '').toLowerCase().includes(query) ); } vlans.sort((a, b) => (a.vlan_id || 0) - (b.vlan_id || 0)); @@ -3233,7 +3337,7 @@ function renderIpamVlans(query) { if (section) section.style.display = ''; if (vlans.length === 0) { - tbody.innerHTML = 'Ei VLANeja vielä.'; + tbody.innerHTML = 'Ei VLANeja vielä.'; } else { tbody.innerHTML = vlans.map(e => ` ${e.vlan_id || '-'} @@ -3241,7 +3345,6 @@ function renderIpamVlans(query) { ${esc(e.nimi || '-')} ${e.site_name ? esc(e.site_name) : ''} ${tilaLabel[e.tila] || e.tila} - ${esc(e.asiakas || '-')} @@ -3259,17 +3362,6 @@ function vlanRefHtml(vlanId) { return `${vlanId}${label ? ` ${label}` : ''}`; } -// --- Asiakas-dropdown populointi --- -async function populateIpamCustomerDropdown(selectedName) { - if (!customers || customers.length === 0) { - try { customers = await apiCall('customers'); } catch(e) {} - } - const sel = document.getElementById('ipam-form-asiakas'); - const sorted = [...customers].sort((a, b) => (a.nimi || '').localeCompare(b.nimi || '')); - sel.innerHTML = '' + - sorted.map(c => ``).join(''); -} - // --- Toggle & Drill --- function ipamToggle(id) { if (ipamExpandedIds.has(id)) ipamExpandedIds.delete(id); @@ -3310,7 +3402,6 @@ async function editIpam(id) { document.getElementById('ipam-form-tila').value = e.tila || 'vapaa'; document.getElementById('ipam-form-lisatiedot').value = e.lisatiedot || ''; document.getElementById('ipam-form-vlan').value = e.vlan_id || ''; - await populateIpamCustomerDropdown(e.asiakas || ''); await loadIpamSitesDropdown(); document.getElementById('ipam-form-site').value = e.site_id || ''; document.getElementById('ipam-modal-title').textContent = e.tyyppi === 'vlan' ? 'Muokkaa VLANia' : 'Muokkaa verkkoa / IP:tä'; @@ -3329,7 +3420,7 @@ 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 populateIpamCustomerDropdown(''); + document.getElementById('ipam-form-tila').value = 'varattu'; await loadIpamSitesDropdown(); document.getElementById('ipam-modal-title').textContent = 'Lisää verkko / IP'; document.getElementById('ipam-modal').style.display = 'flex'; @@ -3339,7 +3430,7 @@ 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 populateIpamCustomerDropdown(''); + document.getElementById('ipam-form-tila').value = 'varattu'; await loadIpamSitesDropdown(); document.getElementById('ipam-modal-title').textContent = 'Lisää VLAN'; document.getElementById('ipam-modal').style.display = 'flex'; @@ -3362,7 +3453,6 @@ document.getElementById('ipam-form')?.addEventListener('submit', async (e) => { nimi: document.getElementById('ipam-form-nimi').value.trim(), site_id: document.getElementById('ipam-form-site').value || null, tila: document.getElementById('ipam-form-tila').value, - asiakas: document.getElementById('ipam-form-asiakas').value.trim(), lisatiedot: document.getElementById('ipam-form-lisatiedot').value.trim(), }; if (id) data.id = id; diff --git a/style.css b/style.css index 0a78ca5..23ea2c4 100644 --- a/style.css +++ b/style.css @@ -1090,6 +1090,10 @@ span.empty { .ipam-bc-current { color:#333; font-weight:600; cursor:default; } .ipam-bc-current:hover { text-decoration:none; } .ipam-usage { display:inline-block; margin-left:0.5rem; padding:1px 6px; border-radius:8px; font-size:0.72rem; font-weight:600; background:#f0f4ff; color:#4338ca; } +.ipam-free-row { background:#f0fdf4 !important; } +.ipam-free-row:hover { background:#dcfce7 !important; } +.ipam-type-free { display:inline-block; padding:1px 8px; border-radius:4px; font-size:0.75rem; font-weight:700; background:#d1fae5; color:#065f46; } +.ipam-network-free { color:#059669; background:#ecfdf5; } /* Role badge */ .role-badge {