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

11
api.php
View File

@@ -1836,6 +1836,17 @@ switch ($action) {
'muokattu' => date('Y-m-d H:i:s'), 'muokattu' => date('Y-m-d H:i:s'),
'muokkaaja' => currentUser(), '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); dbSaveIpam($companyId, $entry);
// Auto-VLAN: jos subnet/ip:llä on vlan_id, luo VLAN automaattisesti jos ei vielä ole // Auto-VLAN: jos subnet/ip:llä on vlan_id, luo VLAN automaattisesti jos ei vielä ole
if ($entry['tyyppi'] !== 'vlan' && !empty($entry['vlan_id'])) { if ($entry['tyyppi'] !== 'vlan' && !empty($entry['vlan_id'])) {

View File

@@ -285,7 +285,6 @@
<th>VLAN</th> <th>VLAN</th>
<th>Sijainti</th> <th>Sijainti</th>
<th>Tila</th> <th>Tila</th>
<th>Asiakas</th>
<th>Toiminnot</th> <th>Toiminnot</th>
</tr> </tr>
</thead> </thead>
@@ -314,7 +313,6 @@
<th>Nimi / Kuvaus</th> <th>Nimi / Kuvaus</th>
<th>Sijainti</th> <th>Sijainti</th>
<th>Tila</th> <th>Tila</th>
<th>Asiakas</th>
<th>Toiminnot</th> <th>Toiminnot</th>
</tr> </tr>
</thead> </thead>
@@ -1036,12 +1034,6 @@
<option value="varattu">Varattu</option> <option value="varattu">Varattu</option>
</select> </select>
</div> </div>
<div class="form-group">
<label for="ipam-form-asiakas">Asiakas</label>
<select id="ipam-form-asiakas">
<option value="">— Ei asiakasta —</option>
</select>
</div>
<div class="form-group full-width"> <div class="form-group full-width">
<label for="ipam-form-lisatiedot">Lisätiedot</label> <label for="ipam-form-lisatiedot">Lisätiedot</label>
<textarea id="ipam-form-lisatiedot" rows="2"></textarea> <textarea id="ipam-form-lisatiedot" rows="2"></textarea>

134
script.js
View File

@@ -3022,6 +3022,80 @@ function isSubnetOf(childNet, childPrefix, childBits, parentNet, parentPrefix, p
const shift = BigInt(parentBits - parentPrefix); const shift = BigInt(parentBits - parentPrefix);
return (childNet >> shift) === (parentNet >> shift); 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 // Laske subnetin käyttöaste: kuinka monta lasta (direct children) vs kapasiteetti
function subnetUsageHtml(node) { function subnetUsageHtml(node) {
if (node.entry.tyyppi !== 'subnet' || node.children.length === 0) return ''; if (node.entry.tyyppi !== 'subnet' || node.children.length === 0) return '';
@@ -3107,11 +3181,32 @@ function flattenTree(nodes, depth, drillId) {
for (const node of list) { for (const node of list) {
const hasChildren = node.children.length > 0; const hasChildren = node.children.length > 0;
const expanded = ipamExpandedIds.has(node.entry.id); 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) { if (hasChildren && expanded) {
// 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); render(node.children, d + 1);
} }
} }
}
}; };
render(nodes, depth); render(nodes, depth);
return rows; return rows;
@@ -3135,7 +3230,6 @@ function renderIpam() {
(e.verkko || '').toLowerCase().includes(query) || (e.verkko || '').toLowerCase().includes(query) ||
(e.nimi || '').toLowerCase().includes(query) || (e.nimi || '').toLowerCase().includes(query) ||
(e.site_name || '').toLowerCase().includes(query) || (e.site_name || '').toLowerCase().includes(query) ||
(e.asiakas || '').toLowerCase().includes(query) ||
(e.lisatiedot || '').toLowerCase().includes(query) || (e.lisatiedot || '').toLowerCase().includes(query) ||
String(e.vlan_id || '').includes(query) String(e.vlan_id || '').includes(query)
); );
@@ -3177,6 +3271,18 @@ function renderIpam() {
tbody.innerHTML = rows.map(r => { tbody.innerHTML = rows.map(r => {
const e = r.entry; const e = r.entry;
const indent = r.depth * 1.5; 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 const toggleIcon = r.hasChildren
? `<span class="ipam-toggle" onclick="event.stopPropagation();ipamToggle('${e.id}')">${r.expanded ? '▼' : '▶'}</span> ` ? `<span class="ipam-toggle" onclick="event.stopPropagation();ipamToggle('${e.id}')">${r.expanded ? '▼' : '▶'}</span> `
: '<span class="ipam-toggle-placeholder"></span> '; : '<span class="ipam-toggle-placeholder"></span> ';
@@ -3195,7 +3301,6 @@ function renderIpam() {
<td>${vlanRefHtml(e.vlan_id)}</td> <td>${vlanRefHtml(e.vlan_id)}</td>
<td>${e.site_name ? esc(e.site_name) : '<span style="color:#ccc;">—</span>'}</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><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()"> <td class="actions-cell" onclick="event.stopPropagation()">
<button class="btn-link" onclick="editIpam('${e.id}')">✎</button> <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> <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) || String(e.vlan_id || '').includes(query) ||
(e.nimi || '').toLowerCase().includes(query) || (e.nimi || '').toLowerCase().includes(query) ||
(e.verkko || '').toLowerCase().includes(query) || (e.verkko || '').toLowerCase().includes(query) ||
(e.site_name || '').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)); 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 (section) section.style.display = '';
if (vlans.length === 0) { 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 { } else {
tbody.innerHTML = vlans.map(e => `<tr> tbody.innerHTML = vlans.map(e => `<tr>
<td><strong>${e.vlan_id || '-'}</strong></td> <td><strong>${e.vlan_id || '-'}</strong></td>
@@ -3241,7 +3345,6 @@ function renderIpamVlans(query) {
<td>${esc(e.nimi || '-')}</td> <td>${esc(e.nimi || '-')}</td>
<td>${e.site_name ? esc(e.site_name) : '<span style="color:#ccc;">—</span>'}</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><span class="ipam-tila ${tilaClass[e.tila] || ''}">${tilaLabel[e.tila] || e.tila}</span></td>
<td>${esc(e.asiakas || '-')}</td>
<td class="actions-cell"> <td class="actions-cell">
<button class="btn-link" onclick="editIpam('${e.id}')">✎</button> <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> <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>` : ''}`; 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 --- // --- Toggle & Drill ---
function ipamToggle(id) { function ipamToggle(id) {
if (ipamExpandedIds.has(id)) ipamExpandedIds.delete(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-tila').value = e.tila || 'vapaa';
document.getElementById('ipam-form-lisatiedot').value = e.lisatiedot || ''; document.getElementById('ipam-form-lisatiedot').value = e.lisatiedot || '';
document.getElementById('ipam-form-vlan').value = e.vlan_id || ''; document.getElementById('ipam-form-vlan').value = e.vlan_id || '';
await populateIpamCustomerDropdown(e.asiakas || '');
await loadIpamSitesDropdown(); await loadIpamSitesDropdown();
document.getElementById('ipam-form-site').value = e.site_id || ''; document.getElementById('ipam-form-site').value = e.site_id || '';
document.getElementById('ipam-modal-title').textContent = e.tyyppi === 'vlan' ? 'Muokkaa VLANia' : 'Muokkaa verkkoa / IP:tä'; 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-id').value = '';
document.getElementById('ipam-form').reset(); document.getElementById('ipam-form').reset();
document.getElementById('ipam-form-tyyppi').value = 'subnet'; document.getElementById('ipam-form-tyyppi').value = 'subnet';
await populateIpamCustomerDropdown(''); document.getElementById('ipam-form-tila').value = 'varattu';
await loadIpamSitesDropdown(); await loadIpamSitesDropdown();
document.getElementById('ipam-modal-title').textContent = 'Lisää verkko / IP'; document.getElementById('ipam-modal-title').textContent = 'Lisää verkko / IP';
document.getElementById('ipam-modal').style.display = 'flex'; 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-id').value = '';
document.getElementById('ipam-form').reset(); document.getElementById('ipam-form').reset();
document.getElementById('ipam-form-tyyppi').value = 'vlan'; document.getElementById('ipam-form-tyyppi').value = 'vlan';
await populateIpamCustomerDropdown(''); document.getElementById('ipam-form-tila').value = 'varattu';
await loadIpamSitesDropdown(); await loadIpamSitesDropdown();
document.getElementById('ipam-modal-title').textContent = 'Lisää VLAN'; document.getElementById('ipam-modal-title').textContent = 'Lisää VLAN';
document.getElementById('ipam-modal').style.display = 'flex'; 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(), nimi: document.getElementById('ipam-form-nimi').value.trim(),
site_id: document.getElementById('ipam-form-site').value || null, site_id: document.getElementById('ipam-form-site').value || null,
tila: document.getElementById('ipam-form-tila').value, tila: document.getElementById('ipam-form-tila').value,
asiakas: document.getElementById('ipam-form-asiakas').value.trim(),
lisatiedot: document.getElementById('ipam-form-lisatiedot').value.trim(), lisatiedot: document.getElementById('ipam-form-lisatiedot').value.trim(),
}; };
if (id) data.id = id; if (id) data.id = id;

View File

@@ -1090,6 +1090,10 @@ span.empty {
.ipam-bc-current { color:#333; font-weight:600; cursor:default; } .ipam-bc-current { color:#333; font-weight:600; cursor:default; }
.ipam-bc-current:hover { text-decoration:none; } .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-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 */
.role-badge { .role-badge {