Quelltext des aktuellen Standes
<?php $this->layout('layouts/main', ['page_title' => $page_title, 'current_route' => $current_route, 'full_width' => true]); ?>
<style>
a { text-decoration:none; } a:hover { text-decoration:none; }
.ipam-wrap { display:flex; flex-direction:column; }
.ipam-util-box { background:var(--color-bg); border:1px solid rgba(0,0,0,.08); border-radius:0.25rem;
box-shadow:0 0 1px rgba(0,0,0,.125),0 1px 3px rgba(0,0,0,.08); padding:7px 12px; display:flex;
flex-direction:column; justify-content:center; flex:1; min-width:240px; }
.ipam-util-head { display:flex; align-items:baseline; justify-content:space-between; margin-bottom:0.35rem; }
.ipam-util-title { font-size:0.72rem; font-weight:700; text-transform:uppercase; letter-spacing:0.05em; color:var(--gray-400); }
.ipam-util-pct { font-size:1.2rem; font-weight:800; color:var(--gray-900); line-height:1; }
.ipam-util-bar { height:7px; background:var(--slate-100); border-radius:9999px; overflow:hidden; }
.ipam-util-fill { height:100%; border-radius:9999px; transition:width .4s ease; }
.ipam-util-sub { font-size:0.72rem; color:var(--gray-400); margin-top:0.3rem; }
.ipam-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(320px, 1fr)); gap:1rem; }
.ipam-card { background:var(--color-bg); border:1px solid rgba(0,0,0,.125);
border-radius:0.25rem; overflow:hidden; box-shadow:0 0 1px rgba(0,0,0,.125),0 1px 3px rgba(0,0,0,.2);
transition:box-shadow 0.15s, transform 0.15s; cursor:pointer; text-decoration:none; color:inherit; display:block; }
.ipam-card:hover { box-shadow:0 0 1px rgba(0,0,0,.125),0 4px 12px rgba(0,0,0,.15); transform:translateY(-1px); }
.ipam-card-stripe { height:3px; }
.ipam-card-body { padding:1rem 1.25rem 0.875rem; }
.ipam-card-title { font-size:0.95rem; font-weight:600; color:var(--color-gray-900); margin-bottom:0.2rem; }
.ipam-card-cidr { font-size:0.8rem; color:var(--color-secondary); font-family:monospace; }
.ipam-card-meta { display:flex; gap:1rem; margin-top:0.75rem; flex-wrap:wrap; }
.ipam-card-stat { display:flex; flex-direction:column; gap:0.1rem; }
.ipam-card-stat-val { font-size:1rem; font-weight:700; color:var(--color-gray-900); }
.ipam-card-stat-lbl { font-size:0.7rem; text-transform:uppercase; letter-spacing:0.05em; color:var(--color-secondary); }
.ipam-card-footer { padding:0.5rem 1.25rem; background:var(--color-gray-50); border-top:1px solid rgba(0,0,0,.125);
font-size:0.75rem; color:var(--color-secondary); }
/* utilization bar on card */
.ipam-card-util { margin-top:0.85rem; }
.ipam-card-util-head { display:flex; align-items:center; justify-content:space-between; font-size:0.72rem; color:var(--gray-400); margin-bottom:0.3rem; }
.ipam-card-util-head strong { color:var(--gray-700); font-weight:700; }
.ipam-card-util-bar { height:6px; background:var(--slate-100); border-radius:9999px; overflow:hidden; }
.ipam-card-util-fill { height:100%; border-radius:9999px; transition:width .4s ease; }
/* Search results */
.ipam-search-results { background:var(--color-bg); border:1px solid rgba(0,0,0,.125); border-radius:0.25rem;
box-shadow:0 0 1px rgba(0,0,0,.125),0 1px 3px rgba(0,0,0,.2); overflow:hidden; margin-bottom:1.5rem; }
.ipam-search-row { display:flex; align-items:center; gap:1rem; padding:0.65rem 1.25rem;
border-bottom:1px solid var(--color-gray-50); text-decoration:none; color:inherit; transition:background 0.1s; }
.ipam-search-row:last-child { border-bottom:none; }
.ipam-search-row:hover { background:var(--color-gray-50); }
.ipam-search-ip { font-family:monospace; font-size:0.9rem; font-weight:700; color:var(--color-gray-900); min-width:120px; }
.ipam-search-host { font-size:0.85rem; color:var(--color-gray-700); flex:1; }
.ipam-search-sub { font-size:0.75rem; color:var(--color-secondary); }
@media(max-width:768px) {
.ipam-grid { grid-template-columns:1fr; }
}
/* View Toggle */
.ipam-view-toggle { display:flex; background:var(--gray-100); border:1px solid var(--gray-300); border-radius:0.25rem; overflow:hidden; }
.ipam-view-btn { background:none; border:none; padding:0.4rem 0.6rem; cursor:pointer; color:var(--gray-500); display:flex; align-items:center; transition:all 0.15s; }
.ipam-view-btn:hover { color:var(--gray-700); background:var(--gray-200); }
.ipam-view-btn.active { background:var(--color-primary); color:var(--color-on-accent); }
/* List view */
.ipam-list-name { font-weight:600; color:var(--color-gray-900); }
.ipam-list-row:hover .ipam-list-name { color:var(--color-primary); }
.ipam-list-mono { font-family:monospace; font-size:0.82rem; color:var(--color-gray-700); }
.ipam-list-muted { font-size:0.82rem; color:var(--color-secondary); }
.ipam-list-num { font-size:0.85rem; font-weight:600; color:var(--color-gray-900); }
.ipam-list-util { min-width:120px; }
.ipam-list-util-bar { height:6px; background:var(--slate-100); border-radius:9999px; overflow:hidden; width:90px; display:inline-block; vertical-align:middle; margin-right:0.5rem; }
.ipam-list-util-fill { height:100%; border-radius:9999px; }
.ipam-list-desc { font-size:0.78rem; color:var(--gray-400); max-width:240px; }
/* Tree view (Supernetz → Kind-Subnetze) */
.ipam-tree { background:var(--color-bg); border:1px solid rgba(0,0,0,.125); border-radius:0.25rem;
box-shadow:0 0 1px rgba(0,0,0,.125),0 1px 3px rgba(0,0,0,.2); overflow:hidden; }
.ipam-tree-row { display:flex; align-items:center; gap:0.75rem; padding:0.55rem 1rem;
border-bottom:1px solid var(--color-gray-50); text-decoration:none; color:inherit; transition:background 0.1s; }
.ipam-tree-row:last-child { border-bottom:none; }
.ipam-tree-row:hover { background:var(--color-gray-50); }
.ipam-tree-guide { color:var(--gray-300); font-family:monospace; white-space:pre; flex-shrink:0; user-select:none; }
.ipam-tree-dot { width:9px; height:9px; border-radius:50%; flex-shrink:0; }
.ipam-tree-name { font-weight:600; color:var(--color-gray-900); }
.ipam-tree-cidr { font-family:monospace; font-size:0.82rem; color:var(--color-secondary); }
.ipam-tree-meta { margin-left:auto; display:flex; align-items:center; gap:1rem; }
.ipam-tree-util-bar { height:6px; width:80px; background:var(--slate-100); border-radius:9999px; overflow:hidden; display:inline-block; vertical-align:middle; }
.ipam-tree-util-fill { height:100%; border-radius:9999px; }
.ipam-tree-num { font-size:0.82rem; color:var(--gray-500); min-width:64px; text-align:right; }
/* Freiraum-Suche Modal (Basis: app.css .modal-*; hier nur Zustand + Maße) */
.iff-modal { display:none; z-index:1080; }
.iff-modal.open { display:flex; }
.iff-modal .modal-card { max-width:560px; width:100%; }
.iff-form { display:flex; flex-wrap:wrap; gap:0.75rem; align-items:flex-end; }
.iff-field { display:flex; flex-direction:column; gap:0.25rem; }
.iff-field label { font-size:0.72rem; font-weight:700; text-transform:uppercase; letter-spacing:0.04em; color:var(--gray-400); }
.iff-results { margin-top:1rem; max-height:340px; overflow:auto; }
.iff-res-row { display:flex; align-items:center; justify-content:space-between; gap:0.75rem;
padding:0.45rem 0.25rem; border-bottom:1px solid var(--gray-100); }
.iff-res-row:last-child { border-bottom:none; }
.iff-res-net { font-family:monospace; font-size:0.9rem; font-weight:700; color:var(--gray-900); }
.iff-hint { font-size:0.78rem; color:var(--gray-400); margin-top:0.5rem; }
</style>
<?php
// Hilfsfunktion für die Auslastungsfarbe
$utilColor = function(?int $pct): string {
if ($pct === null) return '#9ca3af';
if ($pct >= 90) return '#dc2626';
if ($pct >= 75) return '#d97706';
if ($pct >= 50) return '#3c8dbc';
return '#16a34a';
};
?>
<div class="ipam-wrap">
<!-- Content Header -->
<div class="content-header">
<div>
<h1><?= $this->te('IP-Adressverwaltung') ?></h1>
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/"><?= $this->te('Home') ?></a></li>
<li class="breadcrumb-item active">IPAM</li>
</ol>
</div>
<div class="content-header-actions">
<div class="ipam-view-toggle" id="ipamViewToggle">
<button class="ipam-view-btn active" data-mode="cards" title="<?= $this->te('Kachelansicht') ?>" aria-label="<?= $this->te('Kachelansicht') ?>">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>
</svg>
</button>
<button class="ipam-view-btn" data-mode="list" title="<?= $this->te('Listenansicht') ?>" aria-label="<?= $this->te('Listenansicht') ?>">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/>
<line x1="8" y1="18" x2="21" y2="18"/>
<line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/>
<line x1="3" y1="18" x2="3.01" y2="18"/>
</svg>
</button>
<button class="ipam-view-btn" data-mode="tree" title="<?= $this->te('Baumansicht (Supernetze)') ?>" aria-label="<?= $this->te('Baumansicht') ?>">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<rect x="3" y="3" width="7" height="5" rx="1"/><rect x="14" y="9" width="7" height="5" rx="1"/>
<rect x="14" y="16" width="7" height="5" rx="1"/>
<path d="M6.5 8v9.5a1 1 0 0 0 1 1H14M6.5 11.5H14"/>
</svg>
</button>
</div>
<?php if ($can_manage_subnets): ?>
<button type="button" class="btn btn-default" onclick="iffOpen()" title="<?= $this->te('Freien Adressraum innerhalb eines Bereichs finden') ?>"><?= $this->te('Freies Subnetz finden') ?></button>
<a href="/ipam/subnets/create" class="btn btn-primary"><?= $this->te('Subnetz anlegen') ?></a>
<?php endif; ?>
</div>
</div>
<div class="page-body">
<!-- KPI Small Boxes -->
<?php if (!empty($subnets) || $stats['addresses'] > 0): ?>
<div class="kpi-row">
<div class="kpi-col">
<div class="small-box bg-info">
<div class="inner"><h3><?= (int)$stats['subnets'] ?></h3><p><?= $this->te('Subnetze') ?></p></div>
<div class="icon"><i class="fas fa-network-wired"></i></div>
</div>
</div>
<div class="kpi-col">
<div class="small-box bg-primary">
<div class="inner"><h3><?= (int)$stats['addresses'] ?></h3><p><?= $this->te('IP-Adressen') ?></p></div>
<div class="icon"><i class="fas fa-server"></i></div>
</div>
</div>
<div class="kpi-col">
<div class="small-box bg-success">
<div class="inner"><h3><?= (int)$stats['active'] ?></h3><p><?= $this->te('Aktiv') ?></p></div>
<div class="icon"><i class="fas fa-check-circle"></i></div>
</div>
</div>
<div class="kpi-col">
<div class="small-box bg-warning">
<div class="inner"><h3><?= (int)$stats['reserved'] ?></h3><p><?= $this->te('Reserviert') ?></p></div>
<div class="icon"><i class="fas fa-bookmark"></i></div>
</div>
</div>
<!-- Auslastung gesamt -->
<div class="kpi-col" style="flex:2 1 320px;">
<div class="ipam-util-box">
<div class="ipam-util-head">
<span class="ipam-util-title"><?= $this->te('Auslastung gesamt') ?></span>
<span class="ipam-util-pct"><?= $stats['utilization'] !== null ? $stats['utilization'] . '%' : '–' ?></span>
</div>
<div class="ipam-util-bar">
<div class="ipam-util-fill" style="width:<?= $stats['utilization'] ?? 0 ?>%;background:<?= $utilColor($stats['utilization']) ?>;"></div>
</div>
<div class="ipam-util-sub">
<?= $this->te('{anzahl} belegt', ['anzahl' => (int)$stats['used']]) ?><?= $stats['capacity'] !== null ? $this->te(' von {gesamt} nutzbar', ['gesamt' => number_format((int)$stats['capacity'], 0, ',', '.')]) : '' ?>
</div>
</div>
</div>
</div>
<?php endif; ?>
<!-- Filter Card -->
<div class="card card-outline card-primary mb-3">
<div class="filter-card-body">
<form method="GET" action="/ipam" style="display:flex;gap:.5rem;align-items:center;flex-wrap:wrap;">
<div style="position:relative;flex:1;min-width:200px;max-width:340px;">
<input type="text" name="q" value="<?= $this->e($search_query) ?>" class="form-control form-control-sm" style="padding-left:2rem;" placeholder="<?= $this->te('IP, Hostname oder Beschreibung…') ?>">
</div>
<?php if ($search_query === '' && !empty($subnets)):
$vlans = array_filter(array_unique(array_column($subnets, 'vlan')));
sort($vlans);
$locs = array_filter(array_unique(array_column($subnets, 'location')));
sort($locs);
?>
<div style="position:relative;flex:1;min-width:160px;max-width:260px;">
<svg style="position:absolute;left:0.6rem;top:50%;transform:translateY(-50%);color:var(--gray-400);" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
<input type="text" id="ipam-f-q" class="form-control form-control-sm" style="padding-left:2rem;"
placeholder="<?= $this->te('Netz filtern (Name, IP …)') ?>" oninput="ipamFilter()">
</div>
<?php if (!empty($vlans)): ?>
<select id="ipam-f-vlan" class="form-control form-control-sm" style="width:auto;" onchange="ipamFilter()">
<option value=""><?= $this->te('Alle VLANs') ?></option>
<?php foreach ($vlans as $v): ?>
<option value="<?= $this->e($v) ?>">VLAN <?= $this->e($v) ?></option>
<?php endforeach; ?>
</select>
<?php endif; ?>
<?php if (!empty($locs)): ?>
<select id="ipam-f-loc" class="form-control form-control-sm" style="width:auto;" onchange="ipamFilter()">
<option value=""><?= $this->te('Alle Standorte') ?></option>
<?php foreach ($locs as $l): ?>
<option value="<?= $this->e($l) ?>"><?= $this->e($l) ?></option>
<?php endforeach; ?>
</select>
<?php endif; ?>
<span id="ipam-f-count" style="font-size:.78rem;color:var(--color-secondary);white-space:nowrap;"></span>
<button id="ipam-f-reset" type="button" class="btn btn-default btn-sm" style="display:none" onclick="ipamFilterReset()">
<?= $this->te('✕ Filter zurücksetzen') ?>
</button>
<?php endif; ?>
<?php if ($search_query !== ''): ?>
<a href="/ipam" class="btn btn-default btn-sm"><?= $this->te('✕ Suche zurücksetzen') ?></a>
<?php endif; ?>
<button type="submit" class="btn btn-primary btn-sm"><?= $this->te('Suchen') ?></button>
</form>
</div>
</div>
<?php if ($search_query !== ''): ?>
<p style="font-size:0.78rem;font-weight:700;text-transform:uppercase;letter-spacing:0.05em;color:var(--gray-400);margin-bottom:0.6rem;">
<?= $this->te('{anzahl} Treffer für „{suche}"', ['anzahl' => count($search_results), 'suche' => $search_query]) ?>
</p>
<?php if (!empty($search_results)): ?>
<div class="ipam-search-results">
<?php foreach ($search_results as $r): ?>
<a href="/ipam/addresses/<?= $r['id'] ?>" class="ipam-search-row">
<span class="ipam-search-ip"><?= $this->e($r['ip_address']) ?></span>
<span class="ipam-search-host"><?= $this->e($r['hostname'] ?? '–') ?></span>
<?php
$bc = ['active'=>'success','reserved'=>'warning','offline'=>'secondary','free'=>'info'];
$bk = $bc[$r['status']] ?? 'secondary';
$bl = ['active'=>'Aktiv','reserved'=>'Reserviert','offline'=>'Offline','free'=>'Frei'];
?>
<span class="badge badge-<?= $bk ?>"><?= $bl[$r['status']] ?? $this->e($r['status']) ?></span>
<span class="ipam-search-sub"><?= $this->e($r['subnet_name']) ?> (<?= $this->e($r['subnet_network']) ?>/<?= $r['subnet_cidr'] ?>)</span>
</a>
<?php endforeach; ?>
</div>
<?php else: ?>
<p style="font-size:0.88rem;color:var(--gray-500);margin-bottom:1.5rem;"><?= $this->te('Keine Einträge gefunden.') ?></p>
<?php endif; ?>
<?php endif; ?>
<?php if (empty($subnets) && $search_query === ''): ?>
<div style="text-align:center;padding:3rem 1rem;color:var(--gray-400);">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2" style="margin:0 auto 1rem;display:block;"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><path d="M6 6h.01M6 18h.01"/></svg>
<p style="font-size:0.9rem;margin:0 0 1rem;"><?= $this->te('Noch keine Subnetze erfasst.') ?></p>
<?php if ($can_manage_subnets): ?>
<a href="/ipam/subnets/create" style="font-size:0.85rem;color:var(--color-primary);font-weight:600;"><?= $this->te('Erstes Subnetz anlegen →') ?></a>
<?php endif; ?>
</div>
<?php elseif (!empty($subnets)): ?>
<!-- Subnetz-Kacheln -->
<div class="ipam-grid" id="ipam-grid">
<?php foreach ($subnets as $s): $util = $s['utilization'] ?? null; ?>
<a href="/ipam/subnets/<?= $s['id'] ?>" class="ipam-card"
data-name="<?= strtolower(htmlspecialchars($s['name'], ENT_QUOTES)) ?>"
data-network="<?= htmlspecialchars($s['network'] . '/' . $s['cidr'], ENT_QUOTES) ?>"
data-vlan="<?= htmlspecialchars($s['vlan'] ?? '', ENT_QUOTES) ?>"
data-loc="<?= strtolower(htmlspecialchars($s['location'] ?? '', ENT_QUOTES)) ?>"
data-desc="<?= strtolower(htmlspecialchars($s['description'] ?? '', ENT_QUOTES)) ?>">
<div class="ipam-card-stripe" style="background:<?= $this->e($s['color']) ?>;"></div>
<div class="ipam-card-body">
<div class="ipam-card-title" style="display:flex;align-items:center;gap:0.4rem;justify-content:space-between;">
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"><?= $this->e($s['name']) ?></span>
<?php if (empty($s['scan_enabled'])): ?>
<span class="status-pill status-pill--neutral" title="<?= $this->te('Remote-Scan deaktiviert') ?>"><?= $this->te('Scan aus') ?></span>
<?php else: ?>
<span class="status-pill status-pill--success" title="<?= $this->te('Remote-Scan aktiviert') ?>">
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.4"><circle cx="11" cy="11" r="7"/><path d="M21 21l-3.5-3.5"/></svg>
<?= $this->te('Scan') ?>
</span>
<?php endif; ?>
</div>
<div class="ipam-card-cidr"><?= $this->e($s['network']) ?>/<?= $s['cidr'] ?><?= $s['vlan'] ? ' · VLAN ' . $this->e($s['vlan']) : '' ?><?= $s['location'] ? ' · ' . $this->e($s['location']) : '' ?></div>
<div class="ipam-card-meta">
<div class="ipam-card-stat"><span class="ipam-card-stat-val"><?= $s['total_ips'] ?></span><span class="ipam-card-stat-lbl"><?= $this->te('Erfasst') ?></span></div>
<?php if ($s['active_ips'] > 0): ?>
<div class="ipam-card-stat"><span class="ipam-card-stat-val" style="color:var(--green-600);"><?= $s['active_ips'] ?></span><span class="ipam-card-stat-lbl"><?= $this->te('Aktiv') ?></span></div>
<?php endif; ?>
<?php if ($s['reserved_ips'] > 0): ?>
<div class="ipam-card-stat"><span class="ipam-card-stat-val" style="color:var(--amber-600);"><?= $s['reserved_ips'] ?></span><span class="ipam-card-stat-lbl"><?= $this->te('Reserviert') ?></span></div>
<?php endif; ?>
<?php if ($s['offline_ips'] > 0): ?>
<div class="ipam-card-stat"><span class="ipam-card-stat-val" style="color:var(--gray-400);"><?= $s['offline_ips'] ?></span><span class="ipam-card-stat-lbl"><?= $this->te('Offline') ?></span></div>
<?php endif; ?>
</div>
<!-- Auslastung -->
<div class="ipam-card-util">
<div class="ipam-card-util-head">
<span><?= $util !== null ? 'Auslastung' : ($s['is_ipv6'] ? 'IPv6-Netz' : 'Kapazität') ?></span>
<?php if ($util !== null): ?>
<span><strong><?= $util ?>%</strong> · <?= (int)$s['used'] ?>/<?= number_format((int)$s['capacity'], 0, ',', '.') ?></span>
<?php else: ?>
<span><?= $this->te('{anzahl} belegt', ['anzahl' => (int)$s['used']]) ?></span>
<?php endif; ?>
</div>
<div class="ipam-card-util-bar">
<div class="ipam-card-util-fill" style="width:<?= $util ?? 0 ?>%;background:<?= $utilColor($util) ?>;"></div>
</div>
</div>
</div>
<?php if ($s['description']): ?>
<div class="ipam-card-footer"><?= $this->e(mb_strimwidth($s['description'], 0, 80, '…')) ?></div>
<?php endif; ?>
</a>
<?php endforeach; ?>
</div>
<!-- Subnetz-Liste -->
<div class="ipam-list-wrap" id="ipam-list" style="display:none;">
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
<th scope="col" style="width:4px;padding:0;"></th>
<th scope="col"><?= $this->te('Netz') ?></th>
<th scope="col">CIDR</th>
<th scope="col">VLAN</th>
<th scope="col"><?= $this->te('Standort') ?></th>
<th scope="col"><?= $this->te('Scan') ?></th>
<th scope="col"><?= $this->te('IPs') ?></th>
<th scope="col"><?= $this->te('Auslastung') ?></th>
<th scope="col"><?= $this->te('Beschreibung') ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($subnets as $s): $util = $s['utilization'] ?? null; ?>
<tr class="ipam-list-row"
data-name="<?= strtolower(htmlspecialchars($s['name'], ENT_QUOTES)) ?>"
data-network="<?= htmlspecialchars($s['network'] . '/' . $s['cidr'], ENT_QUOTES) ?>"
data-vlan="<?= htmlspecialchars($s['vlan'] ?? '', ENT_QUOTES) ?>"
data-loc="<?= strtolower(htmlspecialchars($s['location'] ?? '', ENT_QUOTES)) ?>"
data-desc="<?= strtolower(htmlspecialchars($s['description'] ?? '', ENT_QUOTES)) ?>"
onclick="location.href='/ipam/subnets/<?= $s['id'] ?>'"
style="cursor:pointer;">
<td style="padding:0;width:4px;"><div style="width:4px;height:100%;min-height:38px;background:<?= $this->e($s['color']) ?>;border-radius:2px 0 0 2px;"></div></td>
<td class="ipam-list-name"><?= $this->e($s['name']) ?></td>
<td class="ipam-list-mono"><?= $this->e($s['network']) ?>/<?= $s['cidr'] ?></td>
<td class="ipam-list-muted"><?= $s['vlan'] ? $this->e($s['vlan']) : '–' ?></td>
<td class="ipam-list-muted"><?= $s['location'] ? $this->e($s['location']) : '–' ?></td>
<td>
<?php if (empty($s['scan_enabled'])): ?>
<span class="status-pill status-pill--neutral" title="<?= $this->te('Remote-Scan deaktiviert') ?>"><?= $this->te('aus') ?></span>
<?php else: ?>
<span class="status-pill status-pill--success" title="<?= $this->te('Remote-Scan aktiviert') ?>"><?= $this->te('an') ?></span>
<?php endif; ?>
</td>
<td class="ipam-list-num"><?= $s['total_ips'] ?></td>
<td class="ipam-list-util">
<?php if ($util !== null): ?>
<span class="ipam-list-util-bar"><span class="ipam-list-util-fill" style="width:<?= $util ?>%;background:<?= $utilColor($util) ?>;"></span></span>
<span class="ipam-list-muted" style="font-weight:700;color:var(--gray-700);"><?= $util ?>%</span>
<?php else: ?>
<span class="ipam-list-muted"><?= $s['is_ipv6'] ? 'IPv6' : '–' ?></span>
<?php endif; ?>
</td>
<td class="ipam-list-desc"><?= $s['description'] ? $this->e(mb_strimwidth($s['description'], 0, 60, '…')) : '' ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<!-- Subnetz-Baum (Supernetz → Kind-Subnetze) -->
<?php if (!empty($subnet_tree)): ?>
<div class="ipam-tree" id="ipam-tree" style="display:none;">
<?php foreach ($subnet_tree as $s):
$util = $s['utilization'] ?? null;
$depth = (int)($s['depth'] ?? 0);
?>
<a href="/ipam/subnets/<?= $s['id'] ?>" class="ipam-tree-row">
<?php if ($depth > 0): ?><span class="ipam-tree-guide"><?= str_repeat(' ', $depth - 1) ?>└─</span><?php endif; ?>
<span class="ipam-tree-dot" style="background:<?= $this->e($s['color']) ?>;"></span>
<span class="ipam-tree-name"><?= $this->e($s['name']) ?></span>
<span class="ipam-tree-cidr"><?= $this->e($s['network']) ?>/<?= $s['cidr'] ?><?= $s['vlan'] ? ' · VLAN ' . $this->e($s['vlan']) : '' ?></span>
<span class="ipam-tree-meta">
<?php if ($util !== null): ?>
<span class="ipam-tree-util-bar"><span class="ipam-tree-util-fill" style="width:<?= $util ?>%;background:<?= $utilColor($util) ?>;"></span></span>
<span class="ipam-list-muted" style="font-weight:700;color:var(--gray-700);min-width:34px;"><?= $util ?>%</span>
<?php else: ?>
<span class="ipam-list-muted" style="min-width:34px;"><?= $s['is_ipv6'] ? 'IPv6' : '–' ?></span>
<?php endif; ?>
<span class="ipam-tree-num"><?= $this->te('{anzahl} IPs', ['anzahl' => $s['total_ips']]) ?></span>
</span>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
<!-- Kein Treffer nach Filtern -->
<div id="ipam-no-results" style="display:none;text-align:center;padding:2.5rem 1rem;color:var(--gray-400);">
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.3" style="margin:0 auto .75rem;display:block;"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/><path d="M8 11h6"/></svg>
<p style="font-size:.88rem;margin:0;"><?= $this->te('Kein Netz entspricht dem Filter.') ?></p>
</div>
<?php endif; ?>
</div>
</div>
<?php if ($can_manage_subnets): ?>
<!-- Freiraum-Suche Modal -->
<div class="modal-overlay iff-modal" id="iffModal">
<div class="modal-card">
<div class="modal-header">
<h2><?= $this->te('Freies Subnetz finden') ?></h2>
<button aria-label="<?= $this->te('Schließen') ?>" type="button" class="modal-close" onclick="iffClose()">×</button>
</div>
<div class="modal-body">
<div class="iff-form">
<?php
$v4subnets = array_values(array_filter($subnets, fn($s) => empty($s['is_ipv6'])));
?>
<?php if (!empty($v4subnets)): ?>
<div class="iff-field" style="flex:1;min-width:180px;">
<label for="iff-parent"><?= $this->te('Aus Subnetz übernehmen') ?></label>
<select id="iff-parent" class="form-control form-control-sm" onchange="iffParent()">
<option value=""><?= $this->te('– manuell eingeben –') ?></option>
<?php foreach ($v4subnets as $s): ?>
<option value="<?= $this->e($s['network']) ?>|<?= (int)$s['cidr'] ?>"><?= $this->e($s['name']) ?> (<?= $this->e($s['network']) ?>/<?= (int)$s['cidr'] ?>)</option>
<?php endforeach; ?>
</select>
</div>
<?php endif; ?>
<div class="iff-field" style="flex:1;min-width:130px;">
<label><?= $this->te('Bereich (Netz)') ?></label>
<input type="text" id="iff-network" class="form-control form-control-sm" placeholder="10.0.0.0">
</div>
<div class="iff-field" style="width:80px;">
<label>/CIDR</label>
<input type="number" id="iff-cidr" class="form-control form-control-sm" min="0" max="32" placeholder="16" oninput="iffRefreshWanted()">
</div>
<div class="iff-field" style="width:110px;">
<label for="iff-wanted"><?= $this->te('Gesuchtes /') ?></label>
<select id="iff-wanted" class="form-control form-control-sm"></select>
</div>
<button type="button" class="btn btn-primary btn-sm" onclick="iffSearch()"><?= $this->te('Suchen') ?></button>
</div>
<div class="iff-hint" id="iff-hint" style="display:none;"></div>
<div class="iff-results" id="iff-results"></div>
</div>
</div>
</div>
<?php endif; ?>
<script>
function ipamFilter() {
var q = (document.getElementById('ipam-f-q')?.value || '').toLowerCase().trim();
var vlan = (document.getElementById('ipam-f-vlan')?.value || '');
var loc = (document.getElementById('ipam-f-loc')?.value || '').toLowerCase();
var active = q || vlan || loc;
var cards = document.querySelectorAll('#ipam-grid .ipam-card');
var rows = document.querySelectorAll('#ipam-list .ipam-list-row');
var shown = 0;
function matchItem(el) {
var matchQ = !q || el.dataset.name.includes(q) || el.dataset.network.includes(q) || el.dataset.desc.includes(q);
var matchV = !vlan || el.dataset.vlan === vlan;
var matchL = !loc || el.dataset.loc === loc;
return matchQ && matchV && matchL;
}
cards.forEach(function(c) {
var show = matchItem(c);
c.style.display = show ? '' : 'none';
if (show) shown++;
});
rows.forEach(function(r) {
r.style.display = matchItem(r) ? '' : 'none';
});
var countEl = document.getElementById('ipam-f-count');
if (countEl) countEl.textContent = active ? (shown + ' / ' + cards.length + ' Netze') : '';
var resetBtn = document.getElementById('ipam-f-reset');
if (resetBtn) resetBtn.style.display = active ? '' : 'none';
var noRes = document.getElementById('ipam-no-results');
if (noRes) noRes.style.display = (active && shown === 0) ? '' : 'none';
}
function ipamFilterReset() {
['ipam-f-q','ipam-f-vlan','ipam-f-loc'].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.value = '';
});
ipamFilter();
}
// ---- Freiraum-Suche (freie Subnetz-Blöcke innerhalb eines Bereichs) ----
function iffEsc(s){ return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,'''); }
function iffRefreshWanted() {
var sel = document.getElementById('iff-wanted');
if (!sel) return;
var parent = parseInt(document.getElementById('iff-cidr').value);
if (isNaN(parent) || parent < 0) parent = 0;
var prev = parseInt(sel.value);
var html = '';
for (var c = Math.max(parent, 1); c <= 32; c++) {
html += '<option value="' + c + '">/' + c + '</option>';
}
sel.innerHTML = html;
// sinnvolle Vorauswahl: vorherige Wahl beibehalten, sonst /24 (bzw. Bereichsgröße)
var def = (!isNaN(prev) && prev >= parent && prev <= 32) ? prev : Math.max(parent, 24);
sel.value = String(def);
}
function iffParent() {
var v = document.getElementById('iff-parent').value;
if (!v) return;
var parts = v.split('|');
document.getElementById('iff-network').value = parts[0] || '';
document.getElementById('iff-cidr').value = parts[1] || '';
iffRefreshWanted();
}
function iffOpen() {
document.getElementById('iff-results').innerHTML = '';
var hint = document.getElementById('iff-hint');
if (hint) hint.style.display = 'none';
iffRefreshWanted();
document.getElementById('iffModal').classList.add('open');
}
function iffClose() { document.getElementById('iffModal').classList.remove('open'); }
function iffSearch() {
var network = document.getElementById('iff-network').value.trim();
var cidr = document.getElementById('iff-cidr').value.trim();
var wanted = document.getElementById('iff-wanted').value;
var res = document.getElementById('iff-results');
var hint = document.getElementById('iff-hint');
if (!network) { res.innerHTML = '<p class="iff-hint">Bitte einen Bereich angeben.</p>'; return; }
res.innerHTML = '<p class="iff-hint">Suche …</p>';
hint.style.display = 'none';
var url = '/ipam/find-free?network=' + encodeURIComponent(network)
+ '&cidr=' + encodeURIComponent(cidr) + '&wanted=' + encodeURIComponent(wanted);
fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function(r){ return r.json(); })
.then(function(d){
if (!d.ok) { res.innerHTML = '<p class="iff-hint" style="color:#dc2626;">' + iffEsc(d.error || 'Fehler.') + '</p>'; return; }
if (!d.free || d.free.length === 0) {
res.innerHTML = '<p class="iff-hint">Kein freier Block dieser Größe im Bereich gefunden.</p>';
return;
}
var rows = d.free.map(function(b){
var label = iffEsc(b.network) + '/' + b.cidr;
var createUrl = '/ipam/subnets/create?network=' + encodeURIComponent(b.network) + '&cidr=' + encodeURIComponent(b.cidr);
return '<div class="iff-res-row"><span class="iff-res-net">' + label + '</span>'
+ '<a class="btn btn-default btn-sm" href="' + createUrl + '">Hier anlegen</a></div>';
}).join('');
res.innerHTML = rows;
if (hint) {
var total = d.total_free || d.free.length;
hint.textContent = d.truncated
? ('Mindestens ' + total + ' freie Blöcke – die ersten ' + d.free.length + ' werden angezeigt.')
: (total + ' freie Blöcke gefunden.');
hint.style.display = '';
}
})
.catch(function(e){ res.innerHTML = '<p class="iff-hint" style="color:#dc2626;">Fehler: ' + iffEsc(e.message) + '</p>'; });
}
(function(){
var m = document.getElementById('iffModal');
if (!m) return;
m.addEventListener('click', function(e){ if (e.target === m) iffClose(); });
document.addEventListener('keydown', function(e){ if (e.key === 'Escape' && m.classList.contains('open')) iffClose(); });
})();
// View Toggle
(function () {
var gridEl = document.getElementById('ipam-grid');
var listEl = document.getElementById('ipam-list');
var treeEl = document.getElementById('ipam-tree');
var btns = document.querySelectorAll('.ipam-view-btn');
var STORAGE_KEY = 'ipam_view_mode';
function applyMode(mode) {
// Baumansicht nur möglich, wenn der Baum existiert (nicht während der Suche)
if (mode === 'tree' && !treeEl) mode = 'cards';
btns.forEach(function(b) { b.classList.toggle('active', b.dataset.mode === mode); });
gridEl && (gridEl.style.display = mode === 'cards' ? '' : 'none');
listEl && (listEl.style.display = mode === 'list' ? '' : 'none');
treeEl && (treeEl.style.display = mode === 'tree' ? '' : 'none');
try { localStorage.setItem(STORAGE_KEY, mode); } catch(e) {}
}
try {
var saved = localStorage.getItem(STORAGE_KEY);
if (saved === 'list' || saved === 'tree') applyMode(saved);
} catch(e) {}
btns.forEach(function(btn) {
btn.addEventListener('click', function() { applyMode(btn.dataset.mode); });
});
}());
</script>