IPAM: Hierarkkinen puunäkymä + VLANit erilleen

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 09:13:53 +02:00
parent 1dc04326aa
commit 565a7b6ab3
3 changed files with 278 additions and 19 deletions

View File

@@ -271,17 +271,18 @@
<div class="sub-tab-content" id="subtab-ipam"> <div class="sub-tab-content" id="subtab-ipam">
<div class="main-container"> <div class="main-container">
<div class="search-bar" style="display:flex;gap:0.5rem;align-items:center;"> <div class="search-bar" style="display:flex;gap:0.5rem;align-items:center;">
<input type="text" id="ipam-search-input" placeholder="Hae IP, VLAN, verkko..." style="flex:1;"> <input type="text" id="ipam-search-input" placeholder="Hae IP, verkko, VLAN..." style="flex:1;">
<button class="btn-primary" id="btn-add-ipam" style="white-space:nowrap;">+ Lisää merkintä</button> <button class="btn-primary" id="btn-add-ipam" style="white-space:nowrap;">+ Lisää verkko / IP</button>
</div> </div>
<div id="ipam-breadcrumb" class="ipam-breadcrumb" style="display:none;"></div>
<div class="table-card"> <div class="table-card">
<table id="ipam-table"> <table id="ipam-table" class="ipam-tree-table">
<thead> <thead>
<tr> <tr>
<th>Tyyppi</th> <th style="width:120px;">Tyyppi</th>
<th>Verkko / IP</th> <th>Verkko / IP</th>
<th>VLAN</th>
<th>Nimi / Kuvaus</th> <th>Nimi / Kuvaus</th>
<th>VLAN</th>
<th>Sijainti</th> <th>Sijainti</th>
<th>Tila</th> <th>Tila</th>
<th>Asiakas</th> <th>Asiakas</th>
@@ -291,11 +292,38 @@
<tbody id="ipam-tbody"></tbody> <tbody id="ipam-tbody"></tbody>
</table> </table>
<div id="no-ipam" class="empty-state" style="display:none;"> <div id="no-ipam" class="empty-state" style="display:none;">
<p>Ei IPAM-merkintöjä vielä.</p> <p>Ei verkkoja tai IP-osoitteita vielä.</p>
</div> </div>
</div> </div>
<div class="summary-bar"> <div class="summary-bar">
<span id="ipam-count">0 merkintää</span> <span id="ipam-count">0 verkkoa</span>
</div>
<!-- VLAN-luettelo -->
<div id="ipam-vlan-section" style="margin-top:1.5rem;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem;">
<h4 style="margin:0;color:#0f3460;">VLANit</h4>
<button class="btn-primary" id="btn-add-vlan" style="white-space:nowrap;font-size:0.82rem;">+ Lisää VLAN</button>
</div>
<div class="table-card">
<table>
<thead>
<tr>
<th style="width:80px;">VLAN</th>
<th>Verkko</th>
<th>Nimi / Kuvaus</th>
<th>Sijainti</th>
<th>Tila</th>
<th>Asiakas</th>
<th>Toiminnot</th>
</tr>
</thead>
<tbody id="ipam-vlan-tbody"></tbody>
</table>
</div>
<div class="summary-bar">
<span id="ipam-vlan-count">0 VLANia</span>
</div>
</div> </div>
</div> </div>
</div> </div>

238
script.js
View File

@@ -2957,6 +2957,102 @@ document.getElementById('btn-cancel-site')?.addEventListener('click', () => {
// ==================== IPAM ==================== // ==================== IPAM ====================
let ipamData = []; 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() { async function loadIpam() {
try { try {
@@ -2967,9 +3063,11 @@ async function loadIpam() {
function renderIpam() { function renderIpam() {
const query = (document.getElementById('ipam-search-input')?.value || '').toLowerCase().trim(); 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) { if (query) {
filtered = ipamData.filter(e => networkEntries = networkEntries.filter(e =>
(e.tyyppi || '').toLowerCase().includes(query) || (e.tyyppi || '').toLowerCase().includes(query) ||
(e.verkko || '').toLowerCase().includes(query) || (e.verkko || '').toLowerCase().includes(query) ||
(e.nimi || '').toLowerCase().includes(query) || (e.nimi || '').toLowerCase().includes(query) ||
@@ -2979,22 +3077,107 @@ function renderIpam() {
String(e.vlan_id || '').includes(query) 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 = `<span class="ipam-bc-link" onclick="ipamDrillTo(-1)">Kaikki verkot</span>` +
ipamDrillStack.map((s, i) =>
` <span style="color:#aaa;"></span> <span class="ipam-bc-link${i === ipamDrillStack.length - 1 ? ' ipam-bc-current' : ''}" onclick="ipamDrillTo(${i})">${esc(s.label)}</span>`
).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 tbody = document.getElementById('ipam-tbody');
const noIpam = document.getElementById('no-ipam'); 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 = ''; tbody.innerHTML = '';
if (noIpam) noIpam.style.display = 'block'; if (noIpam) noIpam.style.display = 'block';
} else { } else {
if (noIpam) noIpam.style.display = 'none'; if (noIpam) noIpam.style.display = 'none';
const tyyppiLabel = { subnet: 'Subnet', vlan: 'VLAN', ip: 'IP' }; tbody.innerHTML = rows.map(r => {
const tilaClass = { vapaa: 'ipam-tila-vapaa', varattu: 'ipam-tila-varattu', reserved: 'ipam-tila-reserved' }; const e = r.entry;
const tilaLabel = { vapaa: 'Vapaa', varattu: 'Varattu', reserved: 'Reserved' }; const indent = r.depth * 1.5;
tbody.innerHTML = filtered.map(e => `<tr> const toggleIcon = r.hasChildren
<td><span style="font-weight:600;">${tyyppiLabel[e.tyyppi] || e.tyyppi}</span></td> ? `<span class="ipam-toggle" onclick="event.stopPropagation();ipamToggle('${e.id}')">${r.expanded ? '▼' : '▶'}</span> `
: '<span class="ipam-toggle-placeholder"></span> ';
const typeTag = e.tyyppi === 'subnet'
? '<span class="ipam-type-subnet">Subnet</span>'
: '<span class="ipam-type-ip">IP</span>';
const drillBtn = (e.tyyppi === 'subnet' && r.hasChildren)
? ` <span class="ipam-drill" onclick="event.stopPropagation();ipamDrillInto('${e.id}','${esc(e.verkko || e.nimi)}')" title="Poraudu sisään">→</span>`
: '';
return `<tr class="ipam-tree-row" onclick="ipamToggle('${e.id}')">
<td style="padding-left:${indent}rem;white-space:nowrap;">
${toggleIcon}${typeTag}
</td>
<td><code class="ipam-network">${esc(e.verkko || '-')}</code>${drillBtn}</td>
<td>${esc(e.nimi || '-')}</td>
<td>${e.vlan_id ? '<strong>' + e.vlan_id + '</strong>' : '-'}</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>
</td>
</tr>`;
}).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 => `<tr>
<td><strong>${e.vlan_id || '-'}</strong></td>
<td><code style="font-size:0.85rem;">${esc(e.verkko || '-')}</code></td> <td><code style="font-size:0.85rem;">${esc(e.verkko || '-')}</code></td>
<td>${e.vlan_id ? '<strong>' + e.vlan_id + '</strong>' : '-'}</td>
<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>${esc(e.asiakas || '-')}</td>
<td class="actions-cell"> <td class="actions-cell">
@@ -3003,7 +3186,28 @@ function renderIpam() {
</td> </td>
</tr>`).join(''); </tr>`).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() { async function loadIpamSitesDropdown() {
@@ -3043,8 +3247,18 @@ async function deleteIpam(id, name) {
document.getElementById('btn-add-ipam')?.addEventListener('click', async () => { 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';
await loadIpamSitesDropdown(); 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'; document.getElementById('ipam-modal').style.display = 'flex';
}); });

View File

@@ -1073,6 +1073,23 @@ span.empty {
.ipam-tila-varattu { background:#fef3cd; color:#856404; } .ipam-tila-varattu { background:#fef3cd; color:#856404; }
.ipam-tila-reserved { background:#e2e3e5; color:#495057; } .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 */
.role-badge { .role-badge {
display: inline-block; display: inline-block;