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:
42
index.html
42
index.html
@@ -271,17 +271,18 @@
|
||||
<div class="sub-tab-content" id="subtab-ipam">
|
||||
<div class="main-container">
|
||||
<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;">
|
||||
<button class="btn-primary" id="btn-add-ipam" style="white-space:nowrap;">+ Lisää merkintä</button>
|
||||
<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ää verkko / IP</button>
|
||||
</div>
|
||||
<div id="ipam-breadcrumb" class="ipam-breadcrumb" style="display:none;"></div>
|
||||
<div class="table-card">
|
||||
<table id="ipam-table">
|
||||
<table id="ipam-table" class="ipam-tree-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tyyppi</th>
|
||||
<th style="width:120px;">Tyyppi</th>
|
||||
<th>Verkko / IP</th>
|
||||
<th>VLAN</th>
|
||||
<th>Nimi / Kuvaus</th>
|
||||
<th>VLAN</th>
|
||||
<th>Sijainti</th>
|
||||
<th>Tila</th>
|
||||
<th>Asiakas</th>
|
||||
@@ -291,11 +292,38 @@
|
||||
<tbody id="ipam-tbody"></tbody>
|
||||
</table>
|
||||
<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 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>
|
||||
|
||||
238
script.js
238
script.js
@@ -2957,6 +2957,102 @@ document.getElementById('btn-cancel-site')?.addEventListener('click', () => {
|
||||
// ==================== IPAM ====================
|
||||
|
||||
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() {
|
||||
try {
|
||||
@@ -2967,9 +3063,11 @@ async function loadIpam() {
|
||||
|
||||
function renderIpam() {
|
||||
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) {
|
||||
filtered = ipamData.filter(e =>
|
||||
networkEntries = networkEntries.filter(e =>
|
||||
(e.tyyppi || '').toLowerCase().includes(query) ||
|
||||
(e.verkko || '').toLowerCase().includes(query) ||
|
||||
(e.nimi || '').toLowerCase().includes(query) ||
|
||||
@@ -2979,22 +3077,107 @@ function renderIpam() {
|
||||
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 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 = '';
|
||||
if (noIpam) noIpam.style.display = 'block';
|
||||
} else {
|
||||
if (noIpam) noIpam.style.display = 'none';
|
||||
const tyyppiLabel = { subnet: 'Subnet', vlan: 'VLAN', ip: 'IP' };
|
||||
const tilaClass = { vapaa: 'ipam-tila-vapaa', varattu: 'ipam-tila-varattu', reserved: 'ipam-tila-reserved' };
|
||||
const tilaLabel = { vapaa: 'Vapaa', varattu: 'Varattu', reserved: 'Reserved' };
|
||||
tbody.innerHTML = filtered.map(e => `<tr>
|
||||
<td><span style="font-weight:600;">${tyyppiLabel[e.tyyppi] || e.tyyppi}</span></td>
|
||||
tbody.innerHTML = rows.map(r => {
|
||||
const e = r.entry;
|
||||
const indent = r.depth * 1.5;
|
||||
const toggleIcon = r.hasChildren
|
||||
? `<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>${e.vlan_id ? '<strong>' + e.vlan_id + '</strong>' : '-'}</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>${esc(e.asiakas || '-')}</td>
|
||||
<td class="actions-cell">
|
||||
@@ -3003,7 +3186,28 @@ function renderIpam() {
|
||||
</td>
|
||||
</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() {
|
||||
@@ -3043,8 +3247,18 @@ async function deleteIpam(id, name) {
|
||||
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 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';
|
||||
});
|
||||
|
||||
|
||||
17
style.css
17
style.css
@@ -1073,6 +1073,23 @@ span.empty {
|
||||
.ipam-tila-varattu { background:#fef3cd; color:#856404; }
|
||||
.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 {
|
||||
display: inline-block;
|
||||
|
||||
Reference in New Issue
Block a user