Quelltext des aktuellen Standes
<?php $this->layout('layouts/main', ['page_title' => $page_title, 'current_route' => $current_route, 'full_width' => true]); ?>
<?php
$util = $utilization;
$utilColor = function(?int $pct): string {
if ($pct === null) return 'var(--gray-400)';
if ($pct >= 90) return 'var(--red-600)';
if ($pct >= 75) return 'var(--amber-600)';
if ($pct >= 50) return 'var(--color-primary)';
return 'var(--green-600)';
};
$freeCount = ($capacity['usable'] !== null) ? max(0, $capacity['usable'] - $used_count) : null;
?>
<style>
a { text-decoration:none; } a:hover { text-decoration:none; }
/* Übersichts-Karten oben */
.sn-overview { display:grid; grid-template-columns:1.4fr 1fr; gap:1rem; margin-bottom:1.25rem; }
@media(max-width:900px){ .sn-overview { grid-template-columns:1fr; } }
.sn-overview .detail-card { margin-bottom:0; }
.sn-overview .detail-card-title,
.sn-map-card-title { display:inline-flex; align-items:center; gap:0.4rem; }
/* Auslastungs-Panel */
.sn-util-top { display:flex; align-items:flex-end; justify-content:space-between; margin-bottom:0.6rem; }
.sn-util-pct { font-size:2rem; font-weight:800; line-height:1; color:var(--gray-900); }
.sn-util-pct small { font-size:0.9rem; font-weight:700; color:var(--gray-400); }
.sn-util-bar { height:10px; background:var(--slate-100); border-radius:9999px; overflow:hidden; }
.sn-util-fill { height:100%; border-radius:9999px; transition:width .5s ease; }
.sn-util-legend { display:flex; gap:1.25rem; margin-top:0.85rem; flex-wrap:wrap; }
.sn-util-stat { display:flex; flex-direction:column; gap:0.1rem; }
.sn-util-stat-v { font-size:1.05rem; font-weight:800; color:var(--gray-900); }
.sn-util-stat-l { font-size:0.68rem; text-transform:uppercase; letter-spacing:0.05em; color:var(--gray-400); font-weight:700; }
/* Meta-Panel */
.sn-meta-grid { display:grid; grid-template-columns:1fr 1fr; gap:0.85rem 1.25rem; }
.sn-meta-row { display:flex; flex-direction:column; gap:0.1rem; }
.sn-meta-l { font-size:0.68rem; text-transform:uppercase; letter-spacing:0.05em; color:var(--gray-400); font-weight:700; }
.sn-meta-v { font-size:0.86rem; color:var(--gray-700); }
.sn-meta-mono { font-family:monospace; }
/* Toolbar */
.sn-toolbar { display:flex; align-items:center; justify-content:space-between; gap:1rem; margin-bottom:1rem; flex-wrap:wrap; }
.sn-filter { display:flex; gap:0.4rem; flex-wrap:wrap; }
.sn-filter-btn { padding:0.25rem 0.75rem; border-radius:0.25rem; font-size:0.78rem; font-weight:600;
border:1px solid var(--color-gray-400); background:var(--color-bg); color:var(--color-gray-700); cursor:pointer; transition:all 0.1s; text-decoration:none; }
.sn-filter-btn.active, .sn-filter-btn:hover { background:var(--color-primary); color:var(--color-on-accent); border-color:var(--color-primary); }
.sn-tool-right { display:flex; gap:0.4rem; align-items:center; }
/* IP-Karte (Visualisierung) */
.sn-map-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(34px, 1fr)); gap:4px; }
.sn-map-cell { aspect-ratio:1/1; border-radius:4px; display:flex; align-items:center; justify-content:center;
font-size:0.6rem; font-weight:700; cursor:default; position:relative; user-select:none;
border:1px solid transparent; transition:transform .08s, box-shadow .08s; min-height:30px; }
.sn-map-cell:hover { transform:scale(1.12); box-shadow:0 2px 8px rgba(0,0,0,.18); z-index:2; }
.sn-map-cell.is-link { cursor:pointer; }
.sn-cell-free { background:var(--gray-100); color:var(--gray-400); border-color:var(--gray-200); }
.sn-cell-used { background:var(--color-primary); color:var(--color-on-accent); }
.sn-cell-used.s-reserved { background:var(--amber-600); }
.sn-cell-used.s-offline { background:var(--gray-400); }
.sn-cell-used.s-free { background:var(--color-primary-pale); color:var(--color-primary-dark); border-color:var(--color-primary-muted); }
.sn-cell-dhcp { background:var(--color-primary-pale); color:var(--color-primary-dark); border:1px dashed var(--color-primary-light); }
.sn-cell-gateway { background:var(--violet-600); color:var(--color-on-accent); }
.sn-gw-suggest { display:inline-flex; align-items:center; gap:0.5rem; flex-wrap:wrap; }
.sn-gw-tag { font-size:0.66rem; font-weight:700; text-transform:uppercase; letter-spacing:0.03em; background:var(--violet-100); color:var(--violet-700); border-radius:9999px; padding:0.1rem 0.45rem; }
.sn-gw-apply { font-size:0.72rem; font-weight:600; cursor:pointer; border:1px solid var(--violet-600); background:var(--violet-600); color:var(--color-on-accent); border-radius:0.35rem; padding:0.15rem 0.55rem; transition:background .12s; }
.sn-gw-apply:hover { background:var(--violet-700); }
.sn-cell-network, .sn-cell-broadcast { background:var(--gray-800); color:var(--slate-300); }
.sn-map-legend { display:flex; gap:1rem; flex-wrap:wrap; margin-top:0.9rem; font-size:0.74rem; color:var(--gray-500); }
.sn-leg { display:inline-flex; align-items:center; gap:0.35rem; }
.sn-leg-swatch { width:13px; height:13px; border-radius:3px; display:inline-block; }
.sn-map-octet { grid-column:1/-1; font-size:0.68rem; font-weight:700; color:var(--gray-400); text-transform:uppercase; letter-spacing:0.05em; margin-top:0.5rem; padding-top:0.4rem; border-top:1px dashed var(--slate-100); }
.sn-map-octet:first-child { margin-top:0; padding-top:0; border-top:none; }
/* Tabelle: Standard .table-wrapper/.data-table aus app.css */
.sn-ip { font-family:monospace; font-weight:700; color:var(--gray-900); }
.sn-host { color:var(--gray-700); }
.sn-type-icon { display:inline-flex; align-items:center; justify-content:center; width:24px; height:24px; border-radius:0.375rem; background:var(--gray-100); }
/* Confirm-Modal: zentrale .modal-*-Komponenten aus app.css; lokal nur Zustands-Toggle und Maße */
.ipam-modal { display:none; z-index:1080; }
.ipam-modal.open { display:flex; }
.ipam-modal .modal-card { max-width:420px; }
.ipam-modal-text { font-size:0.88rem; color:var(--gray-500); margin:0; line-height:1.5; }
@media(max-width:960px) {
.sn-meta-grid { grid-template-columns:1fr; }
}
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
</style>
<div>
<!-- Content Header -->
<div class="content-header">
<div>
<h1>
<span style="display:inline-block;width:12px;height:12px;background:<?= $this->e($subnet['color']) ?>;border-radius:50%;margin-right:0.5rem;vertical-align:middle;"></span>
<?= $this->e($subnet['name']) ?>
</h1>
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/"><?= $this->te('Home') ?></a></li>
<li class="breadcrumb-item"><a href="/ipam">IPAM</a></li>
<li class="breadcrumb-item active"><?= $this->e($subnet['name']) ?></li>
</ol>
</div>
<div class="content-header-actions">
<?php if ($can_manage_ips): ?>
<?php if ($next_free_ip): ?>
<a href="/ipam/subnets/<?= $subnet['id'] ?>/addresses/create?ip=<?= urlencode($next_free_ip) ?>" class="btn btn-default btn-sm" title="<?= $this->te('Erste freie Adresse: {adresse}', ['adresse' => $next_free_ip]) ?>">
<i class="fas fa-magic"></i> <?= $this->te('Nächste freie IP') ?>
</a>
<?php endif; ?>
<a href="/ipam/subnets/<?= $subnet['id'] ?>/addresses/create" class="btn btn-primary btn-sm">
<i class="fas fa-plus"></i> <?= $this->te('IP anlegen') ?>
</a>
<?php endif; ?>
<?php if (!empty($addresses)): ?>
<a href="/ipam/subnets/<?= $subnet['id'] ?>/export" class="btn btn-default btn-sm" title="<?= $this->te('Adressen als CSV exportieren') ?>">
<i class="fas fa-file-csv"></i> <?= $this->te('Export') ?>
</a>
<?php endif; ?>
<?php if ($can_manage_subnets): ?>
<button type="button" class="btn btn-default btn-sm" id="scanBtn" onclick="startScan()">
<i class="fas fa-search"></i> <?= $this->te('Scan starten') ?>
</button>
<a href="/ipam/subnets/<?= $subnet['id'] ?>/edit" class="btn btn-default btn-sm"><?= $this->te('Bearbeiten') ?></a>
<button type="button" class="btn btn-danger btn-sm"
onclick="ipamConfirm('Subnetz löschen?', 'Das Subnetz und alle <?= count($addresses) ?> erfassten IP-Adressen werden unwiderruflich gelöscht.', function(){ document.getElementById('snDeleteForm').submit(); })">
<?= $this->te('Löschen') ?>
</button>
<form method="POST" action="/ipam/subnets/<?= $subnet['id'] ?>/delete" id="snDeleteForm" style="display:none;">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
</form>
<?php endif; ?>
</div>
</div>
<div class="page-body">
<!-- Übersicht: Auslastung + Netzdaten -->
<div class="sn-overview">
<!-- Auslastung -->
<div class="detail-card">
<div class="detail-card-header">
<span class="detail-card-title" style="display:inline-flex;align-items:center;gap:0.4rem;">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 3v18h18"/><path d="M18 17V9M13 17V5M8 17v-3"/></svg>
<?= $this->te('Auslastung') ?>
</span>
</div>
<div class="detail-card-body">
<?php if ($capacity['usable'] !== null): ?>
<div class="sn-util-top">
<div class="sn-util-pct"><?= $util ?? 0 ?><small>%</small></div>
<div style="text-align:right;font-size:0.78rem;color:var(--gray-500);">
<?= $this->te('{belegt} belegt / {nutzbar} nutzbar', ['belegt' => (int)$used_count, 'nutzbar' => number_format((int)$capacity['usable'], 0, ',', '.')]) ?>
</div>
</div>
<div class="sn-util-bar">
<div class="sn-util-fill" style="width:<?= $util ?? 0 ?>%;background:<?= $utilColor($util) ?>;"></div>
</div>
<div class="sn-util-legend">
<div class="sn-util-stat"><span class="sn-util-stat-v" style="color:var(--color-primary);"><?= (int)$used_count ?></span><span class="sn-util-stat-l"><?= $this->te('Belegt') ?></span></div>
<div class="sn-util-stat"><span class="sn-util-stat-v" style="color:var(--green-600);"><?= (int)$freeCount ?></span><span class="sn-util-stat-l"><?= $this->te('Frei') ?></span></div>
<div class="sn-util-stat"><span class="sn-util-stat-v"><?= number_format((int)$capacity['usable'], 0, ',', '.') ?></span><span class="sn-util-stat-l"><?= $this->te('Nutzbar') ?></span></div>
</div>
<?php else: ?>
<p style="font-size:0.85rem;color:var(--gray-400);margin:0;">
<?= $capacity['is_ipv6'] ? 'IPv6-Netz – Auslastung wird nicht abgezählt.' : 'Auslastung für dieses Netz nicht berechenbar.' ?>
<br><strong style="color:var(--gray-700);"><?= (int)$used_count ?></strong> <?= $this->te('Adressen erfasst.') ?>
</p>
<?php endif; ?>
</div>
</div>
<!-- Netzdaten -->
<div class="detail-card">
<div class="detail-card-header">
<span class="detail-card-title" style="display:inline-flex;align-items:center;gap:0.4rem;">
<svg width="13" height="13" 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>
<?= $this->te('Netzdaten') ?>
</span>
</div>
<div class="detail-card-body">
<div class="sn-meta-grid">
<div class="sn-meta-row"><span class="sn-meta-l"><?= $this->te('Netz / CIDR') ?></span><span class="sn-meta-v sn-meta-mono"><?= $this->e($subnet['network']) ?>/<?= $subnet['cidr'] ?></span></div>
<?php if ($capacity['usable'] !== null): ?>
<div class="sn-meta-row"><span class="sn-meta-l"><?= $this->te('Host-Bereich') ?></span><span class="sn-meta-v sn-meta-mono"><?= $this->e($capacity['first']) ?> – <?= $this->e($capacity['last']) ?></span></div>
<?php endif; ?>
<?php if (!empty($subnet['gateway'])): ?>
<div class="sn-meta-row"><span class="sn-meta-l"><?= $this->te('Gateway') ?></span><span class="sn-meta-v sn-meta-mono"><?= $this->e($subnet['gateway']) ?></span></div>
<?php elseif (!empty($subnet['gateway_suggested'])): ?>
<div class="sn-meta-row">
<span class="sn-meta-l"><?= $this->te('Gateway') ?></span>
<span class="sn-meta-v sn-gw-suggest">
<span class="sn-meta-mono"><?= $this->e($subnet['gateway_suggested']) ?></span>
<span class="sn-gw-tag"><?= $this->te('Scan-Vorschlag') ?></span>
<?php if (!empty($can_manage_subnets)): ?>
<form method="POST" action="/ipam/subnets/<?= (int)$subnet['id'] ?>/apply-gateway" style="margin:0;">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
<button type="submit" class="sn-gw-apply" title="<?= $this->te('Vorgeschlagenes Gateway übernehmen') ?>"><?= $this->te('Übernehmen') ?></button>
</form>
<?php endif; ?>
</span>
</div>
<?php endif; ?>
<?php if (!empty($subnet['dns_servers'])): ?>
<div class="sn-meta-row"><span class="sn-meta-l">DNS</span><span class="sn-meta-v sn-meta-mono"><?= $this->e($subnet['dns_servers']) ?></span></div>
<?php endif; ?>
<?php if (!empty($subnet['vlan'])): ?>
<div class="sn-meta-row"><span class="sn-meta-l">VLAN</span><span class="sn-meta-v"><?= $this->e($subnet['vlan']) ?></span></div>
<?php endif; ?>
<?php if (!empty($subnet['location'])): ?>
<div class="sn-meta-row"><span class="sn-meta-l"><?= $this->te('Standort') ?></span><span class="sn-meta-v"><?= $this->e($subnet['location']) ?></span></div>
<?php endif; ?>
<?php if (!empty($subnet['dhcp_range_start']) && !empty($subnet['dhcp_range_end'])): ?>
<div class="sn-meta-row"><span class="sn-meta-l"><?= $this->te('DHCP-Bereich') ?></span><span class="sn-meta-v sn-meta-mono"><?= $this->e($subnet['dhcp_range_start']) ?> – <?= $this->e($subnet['dhcp_range_end']) ?></span></div>
<?php endif; ?>
<div class="sn-meta-row">
<span class="sn-meta-l"><?= $this->te('Remote-Scan') ?></span>
<span class="sn-meta-v">
<?php if (!empty($subnet['scan_enabled'])): ?>
<span class="status-pill status-pill--success"><?= $this->te('Aktiviert') ?></span>
<?php else: ?>
<span class="status-pill status-pill--neutral"><?= $this->te('Deaktiviert') ?></span>
<?php endif; ?>
</span>
</div>
</div>
<?php if (!empty($subnet['description'])): ?>
<div style="margin-top:0.9rem;padding-top:0.85rem;border-top:1px solid var(--gray-100);font-size:0.84rem;color:var(--gray-500);line-height:1.5;"><?= nl2br($this->e($subnet['description'])) ?></div>
<?php endif; ?>
</div>
</div>
</div>
<!-- Scan-Ergebnis (wird via JS befüllt) -->
<div id="scanResult" style="display:none;margin-bottom:1rem;"></div>
<!-- Visuelle IP-Karte -->
<?php if ($address_map !== null && !empty($address_map)): ?>
<div class="detail-card">
<div class="detail-card-header">
<span class="detail-card-title" style="display:inline-flex;align-items:center;gap:0.4rem;">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>
<?= $this->te('IP-Karte') ?>
</span>
<span style="font-size:0.8rem;font-weight:600;color:var(--gray-400);"><?= $this->te('{anzahl} Adressen', ['anzahl' => count($address_map)]) ?></span>
</div>
<div class="detail-card-body">
<div class="sn-map-grid">
<?php
$lastOctetGroup = null;
foreach ($address_map as $cell):
$parts = explode('.', $cell['ip']);
$octet = count($parts) === 4 ? ($parts[0].'.'.$parts[1].'.'.$parts[2].'.x') : null;
// Bei großen Netzen Gruppentrenner je /24 anzeigen
if (count($address_map) > 256 && $octet !== null && $octet !== $lastOctetGroup):
$lastOctetGroup = $octet;
echo '<div class="sn-map-octet">' . htmlspecialchars($octet, ENT_QUOTES) . '</div>';
endif;
$st = $cell['status'];
$addr = $cell['address'];
$host = $parts[3] ?? $cell['ip'];
$cls = 'sn-cell-' . $st;
$title = $cell['ip'];
$link = null;
if ($st === 'used') {
$cls .= ' s-' . ($addr['status'] ?? 'active');
$title = $cell['ip'] . ($addr['hostname'] ? ' · ' . $addr['hostname'] : '') . ' · ' . ($status_labels[$addr['status']] ?? $addr['status']);
$link = '/ipam/addresses/' . $addr['id'];
} elseif ($st === 'free') {
$title = $cell['ip'] . ' · frei';
if ($can_manage_ips) $link = '/ipam/subnets/' . $subnet['id'] . '/addresses/create?ip=' . urlencode($cell['ip']);
} elseif ($st === 'dhcp') {
$title = $cell['ip'] . ' · DHCP-Bereich';
} elseif ($st === 'gateway') {
$title = $cell['ip'] . ' · Gateway'
. ($addr && !empty($addr['hostname']) ? ' · ' . $addr['hostname'] : '');
if ($addr) {
$link = '/ipam/addresses/' . $addr['id'];
}
} elseif ($st === 'network') {
$title = $cell['ip'] . ' · Netzadresse';
} elseif ($st === 'broadcast') {
$title = $cell['ip'] . ' · Broadcast';
}
?>
<?php if ($link): ?>
<a href="<?= $link ?>" class="sn-map-cell is-link <?= $cls ?>" title="<?= htmlspecialchars($title, ENT_QUOTES) ?>"><?= htmlspecialchars($host, ENT_QUOTES) ?></a>
<?php else: ?>
<span class="sn-map-cell <?= $cls ?>" title="<?= htmlspecialchars($title, ENT_QUOTES) ?>"><?= htmlspecialchars($host, ENT_QUOTES) ?></span>
<?php endif; ?>
<?php endforeach; ?>
</div>
<div class="sn-map-legend">
<span class="sn-leg"><span class="sn-leg-swatch" style="background:var(--color-primary);"></span> <?= $this->te('Belegt') ?></span>
<span class="sn-leg"><span class="sn-leg-swatch" style="background:var(--amber-600);"></span> <?= $this->te('Reserviert') ?></span>
<span class="sn-leg"><span class="sn-leg-swatch" style="background:var(--gray-400);"></span> <?= $this->te('Offline') ?></span>
<span class="sn-leg"><span class="sn-leg-swatch" style="background:var(--gray-100);border:1px solid var(--gray-200);"></span> <?= $this->te('Frei') ?></span>
<span class="sn-leg"><span class="sn-leg-swatch" style="background:var(--color-primary-pale);border:1px dashed var(--color-primary-light);"></span> DHCP</span>
<span class="sn-leg"><span class="sn-leg-swatch" style="background:var(--violet-600);"></span> <?= $this->te('Gateway') ?></span>
<span class="sn-leg"><span class="sn-leg-swatch" style="background:var(--gray-800);"></span> <?= $this->te('Netz/Broadcast') ?></span>
</div>
</div>
</div>
<?php endif; ?>
<!-- Filter-Toolbar -->
<div class="sn-toolbar">
<div class="sn-filter" id="filterBar">
<a href="#" class="sn-filter-btn active" data-filter="all" onclick="filterRows('all',this);return false;"><?= $this->t('Alle') ?> (<?= count($addresses) ?>)</a>
<?php
$counts = array_count_values(array_column($addresses, 'status'));
foreach (['active'=>'Aktiv','reserved'=>'Reserviert','offline'=>'Offline','free'=>'Frei'] as $k=>$lbl):
if (!isset($counts[$k])) continue;
?>
<a href="#" class="sn-filter-btn" data-filter="<?= $k ?>" onclick="filterRows('<?= $k ?>',this);return false;"><?= $lbl ?> (<?= $counts[$k] ?>)</a>
<?php endforeach; ?>
</div>
</div>
<?php if (empty($addresses)): ?>
<div class="empty-state">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2" style="margin:0 auto 0.75rem;display:block;"><circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/></svg>
<p style="margin:0 0 0.75rem;font-size:0.9rem;"><?= $this->te('Noch keine IP-Adressen in diesem Subnetz.') ?></p>
<?php if ($can_manage_ips): ?>
<a href="/ipam/subnets/<?= $subnet['id'] ?>/addresses/create" style="color:var(--color-primary);font-size:0.85rem;font-weight:600;"><?= $this->te('Erste IP anlegen →') ?></a>
<?php endif; ?>
</div>
<?php else: ?>
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
<th scope="col"><?= $this->te('IP-Adresse') ?></th>
<th scope="col"><?= $this->te('Hostname') ?></th>
<th scope="col"><?= $this->te('Typ') ?></th>
<th scope="col"><?= $this->te('Status') ?></th>
<th scope="col"><?= $this->te('Verantwortlich') ?></th>
<th scope="col"><?= $this->te('Beschreibung') ?></th>
<th scope="col"></th>
</tr>
</thead>
<tbody id="ipTable">
<?php foreach ($addresses as $a): ?>
<?php
$typeIcons = [
'server'=>'<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#6b7280" stroke-width="2"><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>',
'workstation'=>'<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#6b7280" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>',
'printer'=>'<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#6b7280" stroke-width="2"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 01-2-2v-5a2 2 0 012-2h16a2 2 0 012 2v5a2 2 0 01-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>',
'switch'=>'<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#6b7280" stroke-width="2"><rect x="2" y="7" width="20" height="10" rx="2"/><path d="M6 11h.01M6 13h.01M10 11h.01"/></svg>',
'router'=>'<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#6b7280" stroke-width="2"><circle cx="12" cy="12" r="2"/><path d="M4.93 4.93l2.83 2.83M16.24 7.76l2.83-2.83M4.93 19.07l2.83-2.83M16.24 16.24l2.83 2.83M2 12h2M20 12h2M12 2v2M12 20v2"/></svg>',
];
$icon = $typeIcons[$a['type']] ?? '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#6b7280" stroke-width="2"><circle cx="12" cy="12" r="10"/></svg>';
$pillMap = ['active' => 'status-pill--success', 'reserved' => 'status-pill--warning', 'offline' => 'status-pill--neutral', 'free' => 'status-pill--info'];
$badgeClass = $pillMap[$a['status']] ?? 'status-pill--neutral';
$isGw = !empty($subnet['gateway']) && $subnet['gateway'] === $a['ip_address'];
?>
<tr data-status="<?= $a['status'] ?>">
<td>
<a href="/ipam/addresses/<?= $a['id'] ?>" class="sn-ip"><?= $this->e($a['ip_address']) ?></a>
<?php if ($isGw): ?><span class="status-pill status-pill--purple" style="margin-left:0.4rem;">GW</span><?php endif; ?>
</td>
<td class="sn-host"><?= $this->e($a['hostname'] ?? '–') ?></td>
<td>
<span class="sn-type-icon" title="<?= $this->e($type_labels[$a['type']] ?? $a['type']) ?>"><?= $icon ?></span>
<span style="font-size:0.78rem;color:var(--gray-500);margin-left:0.35rem;"><?= $this->e($type_labels[$a['type']] ?? $a['type']) ?></span>
</td>
<td><span class="status-pill <?= $badgeClass ?>"><?= $this->e($status_labels[$a['status']] ?? $a['status']) ?></span></td>
<td style="font-size:0.82rem;"><?= $this->e($a['responsible'] ?? '–') ?></td>
<td style="font-size:0.82rem;color:var(--gray-500);max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"><?= $this->e($a['description'] ?? '') ?></td>
<td style="text-align:right;">
<?php if ($can_manage_ips): ?>
<a href="/ipam/addresses/<?= $a['id'] ?>/edit" style="font-size:0.78rem;color:var(--gray-500);"><?= $this->te('Bearbeiten') ?></a>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
<!-- Confirm-Modal -->
<div class="modal-overlay ipam-modal" id="ipamModal">
<div class="modal-card">
<div class="modal-header">
<h2 id="ipamModalTitle"><?= $this->te('Bestätigen') ?></h2>
<button aria-label="<?= $this->te('Schließen') ?>" type="button" class="modal-close" id="ipamModalClose">×</button>
</div>
<div class="modal-body">
<p class="ipam-modal-text" id="ipamModalText"></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default btn-sm" id="ipamModalCancel"><?= $this->te('Abbrechen') ?></button>
<button type="button" class="btn btn-danger btn-sm" id="ipamModalOk"><?= $this->te('Löschen') ?></button>
</div>
</div>
</div>
<script>
// ===== Confirm-Modal =====
var _ipamOnOk = null;
function ipamConfirm(title, text, onOk) {
document.getElementById('ipamModalTitle').textContent = title || 'Bestätigen';
document.getElementById('ipamModalText').textContent = text || '';
_ipamOnOk = onOk;
document.getElementById('ipamModal').classList.add('open');
}
(function(){
var m = document.getElementById('ipamModal');
function close(){ m.classList.remove('open'); _ipamOnOk = null; }
document.getElementById('ipamModalCancel').addEventListener('click', close);
document.getElementById('ipamModalClose').addEventListener('click', close);
document.getElementById('ipamModalOk').addEventListener('click', function(){ var f=_ipamOnOk; close(); if(f) f(); });
m.addEventListener('click', function(e){ if(e.target === m) close(); });
document.addEventListener('keydown', function(e){ if(e.key==='Escape' && m.classList.contains('open')) close(); });
})();
// ===== Status-Filter =====
function filterRows(status, el) {
document.querySelectorAll('#filterBar .sn-filter-btn').forEach(function(b) { b.classList.remove('active'); });
el.classList.add('active');
document.querySelectorAll('#ipTable tr').forEach(function(row) {
row.style.display = (status === 'all' || row.dataset.status === status) ? '' : 'none';
});
}
// ===== Scanner =====
var _scanSource = null;
function startScan() {
var btn = document.getElementById('scanBtn');
var resultDiv = document.getElementById('scanResult');
if (!btn || !resultDiv || btn.disabled) return;
if (_scanSource) { _scanSource.close(); _scanSource = null; }
btn.disabled = true;
btn.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="animation:spin 1s linear infinite"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg> Scan läuft…';
var online = [], offline = [], dhcpSkipped = 0, scanned = 0;
<?php
$cidr = (int)$subnet['cidr'];
$numHostsJs = $cidr <= 30 ? ((1 << (32 - $cidr)) - 2) : 0;
?>
var total = <?= max(0, $numHostsJs) ?>;
resultDiv.style.display = 'block';
resultDiv.innerHTML = _scanProgressHtml(0, total, 0);
_scanSource = new EventSource('/ipam/subnets/<?= $subnet['id'] ?>/scan-stream');
_scanSource.onmessage = function(e) {
var d;
try { d = JSON.parse(e.data); } catch(ex) { return; }
if (d.error) {
_scanSource.close(); _scanSource = null;
_resetScanBtn(btn);
resultDiv.innerHTML = '<div style="background:#fef2f2;border:1px solid #fca5a5;border-radius:0.25rem;padding:0.75rem 1rem;color:#dc2626;font-size:0.83rem;">Fehler: ' + escHtml(d.error) + '</div>';
return;
}
if (d.done) {
_scanSource.close(); _scanSource = null;
_resetScanBtn(btn);
_showScanResults(online, offline, dhcpSkipped, d.network || '', d.gateway_suggestion || null);
return;
}
if (d.skipped) {
dhcpSkipped++;
} else {
scanned++;
if (d.status === 'online') online.push({
ip: d.ip,
hostname: d.hostname || null,
mac: d.mac || null,
type: d.type || null,
ports: d.ports || [],
details: d.details || {}
});
else offline.push({ ip: d.ip, hostname: d.hostname || null });
}
resultDiv.innerHTML = _scanProgressHtml(scanned, total - dhcpSkipped, online.length);
};
_scanSource.onerror = function() {
if (!_scanSource || _scanSource.readyState === EventSource.CLOSED) return;
_scanSource.close(); _scanSource = null;
_resetScanBtn(btn);
resultDiv.innerHTML = '<div style="background:#fef2f2;border:1px solid #fca5a5;border-radius:0.25rem;padding:0.75rem 1rem;color:#dc2626;font-size:0.83rem;">Verbindung zum Scan unterbrochen.</div>';
};
}
function _resetScanBtn(btn) {
btn.disabled = false;
btn.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg> Scan starten';
}
function _scanProgressHtml(scanned, total, online) {
var pct = total > 0 ? Math.round(scanned / total * 100) : 0;
return '<div style="background:#fff;border:1px solid #e5e7eb;border-radius:0.25rem;padding:1rem 1.25rem;">' +
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.5rem;">' +
'<span style="font-size:0.82rem;color:#374151;font-weight:600;">Scan läuft…</span>' +
'<span style="font-size:0.78rem;color:#6b7280;">' + scanned + ' / ' + total + ' IPs · <span style="color:#15803d;font-weight:700;">' + online + ' online</span></span>' +
'</div>' +
'<div style="height:6px;background:#f3f4f6;border-radius:9999px;overflow:hidden;">' +
'<div style="height:100%;width:' + pct + '%;background:linear-gradient(90deg,#3c8dbc,#7c3aed);border-radius:9999px;transition:width 0.2s;"></div>' +
'</div>' +
'</div>';
}
var _scanTypeLabels = {server:'Server', workstation:'Workstation', printer:'Drucker', switch:'Switch', router:'Router', ap:'Access Point', camera:'Kamera', phone:'Telefon/VoIP'};
function _scanHostTitle(h) {
var lines = [];
if (h.details) {
for (var k in h.details) {
if (Object.prototype.hasOwnProperty.call(h.details, k) && h.details[k]) lines.push(k + ': ' + h.details[k]);
}
}
if (h.mac) lines.push('MAC: ' + h.mac);
return lines.join('\n');
}
function _showScanResults(online, offline, dhcpSkipped, network, gateway) {
var resultDiv = document.getElementById('scanResult');
if (!resultDiv) return;
var html = '<div style="background:#fff;border:1px solid #e5e7eb;border-radius:0.25rem;overflow:hidden;">';
html += '<div style="padding:0.875rem 1.25rem;background:#f9fafb;border-bottom:1px solid #f3f4f6;display:flex;align-items:center;gap:1.5rem;flex-wrap:wrap;">';
html += '<strong style="font-size:0.82rem;color:#111827;">Scan-Ergebnis: ' + escHtml(network) + '</strong>';
html += '<span style="font-size:0.78rem;padding:0.2rem 0.55rem;border-radius:9999px;background:#dcfce7;color:#15803d;font-weight:700;">' + online.length + ' Online</span>';
html += '<span style="font-size:0.78rem;padding:0.2rem 0.55rem;border-radius:9999px;background:#f3f4f6;color:#6b7280;font-weight:700;">' + offline.length + ' Offline</span>';
if (dhcpSkipped > 0) {
html += '<span style="font-size:0.78rem;padding:0.2rem 0.55rem;border-radius:9999px;background:#e8f4fd;color:#367fa9;font-weight:700;">' + dhcpSkipped + ' DHCP übersprungen</span>';
}
if (gateway) {
html += '<span style="font-size:0.78rem;padding:0.2rem 0.55rem;border-radius:9999px;background:#ede9fe;color:#7c3aed;font-weight:700;" title="Wird beim Import als Gateway-Vorschlag gespeichert">Gateway-Vorschlag: ' + escHtml(gateway) + '</span>';
}
html += '</div>';
if (online.length > 0) {
html += '<div style="padding:0.75rem 1.25rem;display:flex;flex-wrap:wrap;gap:0.5rem;">';
online.forEach(function(h) {
html += '<div style="display:inline-flex;flex-direction:column;background:#dcfce7;border-radius:0.375rem;padding:0.25rem 0.6rem;max-width:180px;" title="' + escHtml(_scanHostTitle(h)) + '">';
html += '<span style="font-family:monospace;font-size:0.8rem;font-weight:700;color:#15803d;">' + escHtml(h.ip) + '</span>';
if (h.hostname) {
html += '<span style="font-size:0.68rem;color:#166534;opacity:0.85;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" title="' + escHtml(h.hostname) + '">' + escHtml(h.hostname) + '</span>';
}
var meta = [];
if (h.type && _scanTypeLabels[h.type]) meta.push(_scanTypeLabels[h.type]);
if (h.ports && h.ports.length) meta.push(h.ports.length + ' Port' + (h.ports.length !== 1 ? 's' : ''));
if (h.mac) meta.push('MAC');
if (meta.length) {
html += '<span style="font-size:0.65rem;color:#166534;opacity:0.7;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' + escHtml(meta.join(' · ')) + '</span>';
}
html += '</div>';
});
html += '</div>';
<?php if ($can_manage_ips): ?>
html += '<div style="padding:0.6rem 1.25rem;border-top:1px solid #f3f4f6;display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap;">';
html += '<button type="button" id="importScanBtn" class="btn btn-sm btn-primary">📥 Alle ' + online.length + ' importieren</button>';
html += '<span id="importScanMsg" style="font-size:0.8rem;color:#6b7280;"></span>';
html += '</div>';
resultDiv.innerHTML = html + '</div>';
document.getElementById('importScanBtn').addEventListener('click', function() { importScan(online, gateway); });
return;
<?php endif; ?>
} else {
html += '<div style="padding:0.875rem 1.25rem;font-size:0.83rem;color:#9ca3af;">Keine erreichbaren Hosts gefunden.</div>';
}
html += '</div>';
resultDiv.innerHTML = html;
}
function importScan(ips, gateway) {
var btn = document.getElementById('importScanBtn');
var msg = document.getElementById('importScanMsg');
if (!btn || !msg) return;
btn.disabled = true;
btn.textContent = 'Importiere…';
msg.textContent = '';
msg.style.color = '#6b7280';
var csrf = document.querySelector('meta[name="csrf-token"]');
fetch('/ipam/subnets/<?= $subnet['id'] ?>/import-scan', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrf ? csrf.content : '',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({ ips: ips, gateway_suggestion: gateway || null })
})
.then(function(r) { return r.json(); })
.then(function(data) {
btn.disabled = false;
if (data.success) {
btn.textContent = '✓ Importiert';
btn.style.background = '#16a34a';
msg.textContent = data.message;
msg.style.color = '#16a34a';
if (data.created > 0) {
setTimeout(function() { location.reload(); }, 1500);
}
} else {
btn.textContent = 'Fehler';
btn.style.background = '#dc2626';
msg.textContent = data.error || 'Import fehlgeschlagen';
msg.style.color = '#dc2626';
}
})
.catch(function() {
btn.disabled = false;
btn.textContent = 'Fehler';
msg.textContent = 'Verbindungsfehler';
msg.style.color = '#dc2626';
});
}
function escHtml(str) {
var d = document.createElement('div');
d.textContent = str || '';
return d.innerHTML;
}
</script>