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 {
|