IPAM: duplikaatti-IP-tarkistus, vapaat lohkot, asiakas-kentän poisto + varattu oletus

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 09:50:18 +02:00
parent 8a73423bf1
commit 44053d27f2
4 changed files with 128 additions and 31 deletions

136
script.js
View File

@@ -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 `<tr class="ipam-tree-row ipam-free-row">
<td style="padding-left:${indent}rem;white-space:nowrap;">
<span class="ipam-toggle-placeholder"></span> <span class="ipam-type-free">Vapaa</span>
</td>
<td><code class="ipam-network ipam-network-free">${esc(e.verkko)}</code></td>
<td colspan="4" style="color:#999;font-style:italic;">Käytettävissä</td>
</tr>`;
}
const toggleIcon = r.hasChildren
? `<span class="ipam-toggle" onclick="event.stopPropagation();ipamToggle('${e.id}')">${r.expanded ? '▼' : '▶'}</span> `
: '<span class="ipam-toggle-placeholder"></span> ';
@@ -3195,7 +3301,6 @@ function renderIpam() {
<td>${vlanRefHtml(e.vlan_id)}</td>
<td>${e.site_name ? esc(e.site_name) : '<span style="color:#ccc;">—</span>'}</td>
<td><span class="ipam-tila ${tilaClass[e.tila] || ''}">${tilaLabel[e.tila] || e.tila}</span></td>
<td>${esc(e.asiakas || '-')}</td>
<td class="actions-cell" onclick="event.stopPropagation()">
<button class="btn-link" onclick="editIpam('${e.id}')">✎</button>
<button class="btn-link" style="color:#dc2626;" onclick="deleteIpam('${e.id}','${esc(e.nimi || e.verkko)}')">🗑</button>
@@ -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 = '<tr><td colspan="7" style="text-align:center;color:#aaa;padding:1rem;">Ei VLANeja vielä.</td></tr>';
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:#aaa;padding:1rem;">Ei VLANeja vielä.</td></tr>';
} else {
tbody.innerHTML = vlans.map(e => `<tr>
<td><strong>${e.vlan_id || '-'}</strong></td>
@@ -3241,7 +3345,6 @@ function renderIpamVlans(query) {
<td>${esc(e.nimi || '-')}</td>
<td>${e.site_name ? esc(e.site_name) : '<span style="color:#ccc;">—</span>'}</td>
<td><span class="ipam-tila ${tilaClass[e.tila] || ''}">${tilaLabel[e.tila] || e.tila}</span></td>
<td>${esc(e.asiakas || '-')}</td>
<td class="actions-cell">
<button class="btn-link" onclick="editIpam('${e.id}')">✎</button>
<button class="btn-link" style="color:#dc2626;" onclick="deleteIpam('${e.id}','${esc(e.nimi || e.verkko)}')">🗑</button>
@@ -3259,17 +3362,6 @@ function vlanRefHtml(vlanId) {
return `<strong>${vlanId}</strong>${label ? ` <small style="color:#888;">${label}</small>` : ''}`;
}
// --- 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 = '<option value="">— Ei asiakasta —</option>' +
sorted.map(c => `<option value="${esc(c.nimi)}" ${c.nimi === selectedName ? 'selected' : ''}>${esc(c.nimi)}</option>`).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;