Quelltext des aktuellen Standes
<?php $this->layout('layouts/main', ['page_title' => $page_title, 'current_route' => $current_route, 'full_width' => true, 'extra_css' => ['/assets/css/ssd.css']]); ?>
<script src="<?= $this->asset('/assets/js/ssd.js') ?>"></script>
<style>
/* ---- Sync-Health ---- */
.unifi-synchealth { font-size: .8rem; color: var(--color-text-muted); margin: -.4rem 0 1rem; display: flex; align-items: center; gap: .4rem; }
.unifi-synchealth.is-error { color: var(--red-600); font-weight: 600; }
/* ---- Ports ---- */
.uf-ports-link { display: inline-block; margin-top: .15rem; font-size: .7rem; color: var(--color-primary); cursor: pointer; }
.uf-ports-link:hover { text-decoration: underline; }
.uf-ports-summary { font-size: .82rem; color: var(--color-text-muted); margin-bottom: .6rem; }
.uf-port-table { width: 100%; border-collapse: collapse; font-size: .8rem; }
.uf-port-table th, .uf-port-table td { text-align: left; padding: .35rem .5rem; border-bottom: 1px solid var(--gray-100); white-space: nowrap; }
.uf-port-table th { color: var(--gray-500); font-weight: 600; }
.uf-port-up { color: var(--green-700); font-weight: 600; }
.uf-port-down { color: var(--gray-400); }
.uf-poe-on { color: var(--amber-700); font-weight: 600; }
.uf-poe-off { color: var(--gray-400); }
.uf-port-conn-device { font-weight: 600; }
/* ---- Site-Cards ---- */
.unifi-site-card { background: var(--color-bg); border: 1px solid var(--gray-200); border-radius:0.25rem; margin-bottom: 1.25rem; overflow: hidden; }
.unifi-site-header { display: flex; align-items: center; gap: .75rem; padding: 1rem 1.25rem; background: var(--slate-50); border-bottom: 1px solid var(--gray-200); }
.unifi-site-name { font-weight: 600; font-size: .97rem; flex: 1; }
.unifi-site-badges { display: flex; gap: .4rem; }
/* ---- Toast ---- */
.unifi-toast { position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 9999; display: flex; flex-direction: column; gap: .5rem; }
.unifi-toast-item { padding: .75rem 1.25rem; border-radius: .5rem; font-size: .85rem; font-weight: 500; box-shadow: 0 4px 12px rgba(0,0,0,.15); animation: slideIn .25s ease; }
.unifi-toast-item.ok { background: var(--emerald-600); color: var(--color-on-accent); }
.unifi-toast-item.err { background: var(--red-600); color: var(--color-on-accent); }
@keyframes slideIn { from { transform: translateX(120%); } to { transform: translateX(0); } }
/* ---- Modal ---- */
/* Modal: Basis aus app.css; hier Zustands-Toggle, Ebene und scrollender Body */
.uf-modal-backdrop { display: none; z-index: 10001; }
.uf-modal-backdrop.open { display: flex; }
.uf-modal-backdrop .modal-card { max-height: 85vh; display: flex; flex-direction: column; overflow: hidden; }
.uf-modal-backdrop .modal-body { overflow: auto; font-size: .85rem; line-height: 1.5; }
.uf-prev-table { width: 100%; border-collapse: collapse; font-size: .78rem; margin-top: .6rem; }
.uf-prev-table th, .uf-prev-table td { text-align: left; padding: .3rem .5rem; border-bottom: 1px solid var(--gray-100); }
.uf-prev-create { color: var(--green-700); font-weight: 600; }
.uf-prev-update { color: var(--amber-700); font-weight: 600; }
/* ---- Clients-Panel ---- */
.unifi-clients { border-top: 1px solid var(--gray-100); }
.unifi-clients > summary {
cursor: pointer; padding: .6rem 1.25rem; font-size: .82rem; font-weight: 600;
color: var(--gray-700); background: var(--color-gray-50); list-style: none; user-select: none;
display: flex; align-items: center; gap: .4rem;
}
.unifi-clients > summary::-webkit-details-marker { display: none; }
.unifi-clients > summary::before { content: '▸'; font-size: .7rem; color: var(--gray-400); transition: transform .15s; }
.unifi-clients[open] > summary::before { transform: rotate(90deg); }
.unifi-clients > summary:hover { background: var(--gray-100); }
.unifi-clients-count { color: var(--gray-400); font-weight: 400; }
/* ---- Countdown ---- */
.unifi-countdown { font-size: .8rem; color: var(--gray-500); white-space: nowrap; }
/* Searchable Dropdown: gemeinsame Basis kommt aus /assets/css/ssd.css.
Hier nur seitenspezifische Abweichungen. */
.ssd--site .ssd__trigger { min-width: 280px; }
.ssd--site .ssd__panel { min-width: 320px; }
.ssd__list { max-height: 420px; }
/* ---- Toolbar ---- */
.unifi-toolbar { display: flex; gap: .55rem; align-items: center; flex-wrap: wrap; width: 100%; }
.unifi-toolbar-divider { width: 1px; height: 24px; background: var(--gray-200); margin: 0 .25rem; }
.unifi-toolbar-meta { margin-left: auto; display: flex; align-items: center; gap: .8rem; font-size: .76rem; color: var(--gray-500); }
/* Icon-only Refresh-Button */
.unifi-icon-btn { width: 32px; height: 32px; padding: 0; display: inline-flex; align-items: center; justify-content: center; font-size: 1.05rem; line-height: 1; }
.unifi-icon-btn:disabled { opacity: .6; }
/* Type-Filter als Dropdown */
.unifi-type-select { width: auto; min-width: 160px; height: 32px; padding: .2rem .5rem; font-size: .85rem; }
/* ---- Voucher-CTA-Button ---- */
.unifi-voucher-cta { font-weight: 600; box-shadow: 0 1px 2px rgba(0,0,0,.08); }
.unifi-voucher-cta:hover { filter: brightness(.95); }
</style>
<?php
$totalDevices = 0;
$onlineDevices = 0;
$offlineDevices = 0;
$apCount = 0;
$swCount = 0;
foreach ($siteGroups as $sg) {
foreach ($sg['devices'] as $d) {
$totalDevices++;
if ((int)$d['state'] === 1) { $onlineDevices++; } else { $offlineDevices++; }
if ($d['type'] === 'uap') $apCount++;
if ($d['type'] === 'usw') $swCount++;
}
}
$siteIds = array_keys($siteGroups);
$multiSite = count($siteIds) > 1;
// Standardmäßig nur die erste Site anzeigen – sonst werden alle Sites
// gleichzeitig angezeigt und die Seite wird unübersichtlich.
$defaultSiteId = $siteIds[0] ?? '';
function unifiTypeBadge(string $type): string {
return match($type) {
'uap' => '<span class="status-pill status-pill--info">📡 AP</span>',
'usw' => '<span class="status-pill status-pill--success">🔗 Switch</span>',
'ugw','udm','uxg' => '<span class="status-pill status-pill--purple">🔍 Gateway</span>',
default => '<span class="status-pill status-pill--neutral">' . htmlspecialchars(strtoupper($type)) . '</span>',
};
}
function unifiStateBadge(int $state): string {
// UniFi-Gerätestatus-Codes auf verständliche Labels mappen.
[$cls, $label] = match($state) {
1 => ['online', 'Online'],
0 => ['offline', 'Offline'],
2 => ['pending', 'Ausstehend'],
4 => ['pending', 'Update läuft'],
5 => ['pending', 'Provisionierung'],
6 => ['offline', 'Kein Heartbeat'],
7 => ['pending', 'Adoptierung'],
9 => ['offline', 'Inform-Fehler'],
default => ['pending', 'Status ' . $state],
};
$pillCls = ['online' => 'status-pill--success', 'offline' => 'status-pill--danger', 'pending' => 'status-pill--caution'][$cls];
$dotCls = ['online' => 'status-dot--success status-dot--pulse', 'offline' => 'status-dot--danger', 'pending' => 'status-dot--warning'][$cls];
return '<span class="status-dot ' . $dotCls . '"></span> <span class="status-pill ' . $pillCls . '">' . htmlspecialchars($label) . '</span>';
}
function unifiClientRows(array $clients): string {
if (empty($clients)) {
return '<tr><td colspan="5" style="text-align:center;color:var(--gray-400);padding:.75rem">Keine verbundenen Clients</td></tr>';
}
$html = '';
foreach ($clients as $c) {
$conn = $c['is_wired']
? '<span class="status-pill status-pill--success">🔗 Kabel' . (!empty($c['uplink_port']) ? ' P' . (int)$c['uplink_port'] : '') . '</span>'
: '<span class="status-pill status-pill--info">📶 WLAN' . (isset($c['signal']) && $c['signal'] !== null ? ' ' . (int)$c['signal'] . 'dBm' : '') . '</span>';
$html .= '<tr>'
. '<td>' . $conn . '</td>'
. '<td><strong>' . htmlspecialchars($c['hostname'] ?: '—') . '</strong></td>'
. '<td><code style="font-size:.8rem">' . htmlspecialchars($c['ip'] ?: '–') . '</code></td>'
. '<td><code style="font-size:.78rem;color:var(--gray-400)">' . htmlspecialchars($c['mac'] ?: '–') . '</code></td>'
. '<td style="font-size:.78rem;color:var(--gray-500)">' . htmlspecialchars($c['network'] ?: '–') . '</td>'
. '</tr>';
}
return $html;
}
function unifiAgo(?string $dt): string {
if (!$dt) return '–';
$ts = strtotime($dt);
if ($ts === false) return '–';
$s = max(0, time() - $ts);
if ($s < 60) return 'vor ' . $s . 's';
if ($s < 3600) return 'vor ' . intdiv($s, 60) . ' Min';
if ($s < 86400) return 'vor ' . intdiv($s, 3600) . ' Std';
return 'vor ' . intdiv($s, 86400) . ' Tg';
}
function unifiUptime(?int $secs): string {
if ($secs === null || $secs <= 0) return '–';
$d = intdiv($secs, 86400);
$h = intdiv($secs % 86400, 3600);
$m = intdiv($secs % 3600, 60);
if ($d > 0) return $d . 'd ' . $h . 'h';
if ($h > 0) return $h . 'h ' . $m . 'm';
return $m . ' min';
}
// Kompakte Relativzeit „seit wann" (aktueller Online-/Offline-Zustand)
function unifiSince(?string $dt): string {
if (empty($dt)) return '';
$ts = strtotime($dt);
if ($ts === false) return '';
$s = max(0, time() - $ts);
$d = intdiv($s, 86400); $h = intdiv($s % 86400, 3600); $m = intdiv($s % 3600, 60);
if ($d > 0) return $d . 'd';
if ($h > 0) return $h . 'h';
if ($m > 0) return $m . ' min';
return 'gerade eben';
}
?>
<div class="unifi-wrap">
<div class="content-header">
<div>
<h1><?= $this->te('UniFi-Integration') ?></h1>
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/"><?= $this->te('Home') ?></a></li>
<li class="breadcrumb-item active"><?= $this->te('UniFi-Integration') ?></li>
</ol>
</div>
<div class="content-header-actions">
<a href="/unifi/topology" class="btn btn-default"><?= $this->te('Topologie') ?></a>
<?php if (!empty($can_view_assets)): ?>
<a href="/unifi/inventory-match" class="btn btn-default"><?= $this->te('Inventar-Abgleich') ?></a>
<?php endif; ?>
<?php if ($can_vouchers && !empty($siteGroups)): ?>
<a href="/unifi/vouchers" class="btn btn-primary unifi-voucher-cta">
<?= $this->te('🎪 Voucher-Generator') ?>
</a>
<?php endif; ?>
</div>
</div>
<div class="page-body">
<?php if ($configured): ?>
<!-- KPI small-boxes -->
<div class="kpi-row">
<div class="kpi-col">
<div class="small-box bg-info">
<div class="inner"><h3 id="stat-total"><?= $totalDevices ?></h3><p><?= $this->te('Geräte gesamt') ?></p></div>
<div class="icon"><i class="fas fa-network-wired"></i></div>
</div>
</div>
<div class="kpi-col">
<div class="small-box bg-success">
<div class="inner"><h3 id="stat-online"><?= $onlineDevices ?></h3><p><?= $this->te('Online') ?></p></div>
<div class="icon"><i class="fas fa-check-circle"></i></div>
</div>
</div>
<div class="kpi-col">
<div class="small-box <?= $offlineDevices > 0 ? 'bg-warning' : 'bg-secondary' ?>">
<div class="inner"><h3 id="stat-offline"><?= $offlineDevices ?></h3><p><?= $this->te('Offline') ?></p></div>
<div class="icon"><i class="fas fa-times-circle"></i></div>
</div>
</div>
<div class="kpi-col">
<div class="small-box bg-secondary">
<div class="inner"><h3><span id="stat-ap"><?= $apCount ?></span> / <span id="stat-sw"><?= $swCount ?></span></h3><p><?= $this->te('APs / Switches') ?></p></div>
<div class="icon"><i class="fas fa-wifi"></i></div>
</div>
</div>
<div class="kpi-col">
<div class="small-box <?= (int)($updatesAvailable ?? 0) > 0 ? 'bg-warning' : 'bg-secondary' ?>">
<div class="inner"><h3><?= (int)($updatesAvailable ?? 0) ?></h3><p><?= $this->te('Firmware-Updates') ?></p></div>
<div class="icon"><i class="fas fa-download"></i></div>
</div>
</div>
</div>
<?php if (!empty($lastSync)): ?>
<div class="unifi-synchealth<?= empty($lastSync['ok']) ? ' is-error' : '' ?>">
<?php if (!empty($lastSync['ok'])): ?>
<i class="fas fa-check-circle"></i> <?= $this->te('Letzter Sync: {zeitpunkt}', ['zeitpunkt' => $lastSync['at'] ?? '–']) ?>
· <?= (int)($lastSync['devices'] ?? 0) ?> Geräte, <?= (int)($lastSync['clients'] ?? 0) ?> Clients, <?= (int)($lastSync['sites'] ?? 0) ?> Sites
<?php else: ?>
<i class="fas fa-exclamation-triangle"></i> <?= $this->te('Letzter Sync fehlgeschlagen ({zeitpunkt}):', ['zeitpunkt' => $lastSync['at'] ?? '–']) ?> <?= htmlspecialchars($lastSync['error'] ?? 'Unbekannter Fehler') ?>
<?php endif; ?>
</div>
<?php endif; ?>
<!-- Toolbar filter card -->
<div class="card card-outline card-primary mb-3" >
<div class="filter-card-body">
<div class="unifi-toolbar">
<?php if (!empty($siteGroups)):
$defaultSiteName = $siteGroups[$defaultSiteId]['site_name'] ?? $defaultSiteId;
?>
<div class="ssd ssd--site" id="site-ssd">
<div class="ssd__trigger" onclick="ssdToggle('site-ssd')">
<span class="ssd__value" id="site-ssd-label">🌐 <?= htmlspecialchars($defaultSiteName ?: $defaultSiteId) ?></span>
<span class="ssd__arrow">▼</span>
</div>
<div class="ssd__panel" style="display:none">
<input class="ssd__search" placeholder="<?= $this->te('Site suchen…') ?>" oninput="ssdFilter('site-ssd', this.value)" autocomplete="off">
<div class="ssd__list">
<?php if ($multiSite): ?>
<div class="ssd__item" data-value="all" data-name="Alle Sites"><?= $this->te('🌐 Alle Sites') ?></div>
<?php endif; ?>
<?php foreach ($siteGroups as $siteId => $sg):
$count = count($sg['devices']);
?>
<div class="ssd__item <?= $siteId === $defaultSiteId ? 'selected' : '' ?>"
data-value="<?= htmlspecialchars($siteId) ?>"
data-name="<?= htmlspecialchars($sg['site_name'] ?: $siteId) ?>">
🌐 <?= htmlspecialchars($sg['site_name'] ?: $siteId) ?>
<span style="color:var(--gray-400);font-size:.72rem;">(<?= $count ?>)</span>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<select id="unifi-type-select" class="form-control form-control-sm unifi-type-select" onchange="setTypeFilter(this.value)">
<option value="all"><?= $this->te('Alle Geräte') ?></option>
<option value="uap"><?= $this->te('📡 Access Points') ?></option>
<option value="usw"><?= $this->te('🔌 Switches') ?></option>
<option value="gw"><?= $this->te('🔍 Gateways') ?></option>
</select>
<?php endif; ?>
<?php if ($can_manage): ?>
<button id="btn-refresh" class="btn btn-default btn-sm unifi-icon-btn" onclick="doRefresh()" title="<?= $this->te('Jetzt aktualisieren') ?>" aria-label="<?= $this->te('Jetzt aktualisieren') ?>">↻</button>
<div class="unifi-toolbar-divider"></div>
<button class="btn btn-default btn-sm" onclick="doSync('ipam')" title="<?= $this->te('IPAM-Sync') ?>"><?= $this->te('🌐 IPAM') ?></button>
<button class="btn btn-default btn-sm" onclick="doSync('vlans')" title="<?= $this->te('VLAN-Sync') ?>"><?= $this->te('📋 VLAN') ?></button>
<?php endif; ?>
<div class="unifi-toolbar-meta">
<span class="unifi-countdown" id="refresh-countdown"></span>
<span id="last-refresh-label">
<?= $lastRefresh
? 'Aktualisiert: ' . htmlspecialchars(date('d.m.Y H:i:s', strtotime($lastRefresh)))
: 'Noch kein Datenabruf' ?>
</span>
</div>
</div>
</div>
</div>
<?php endif; ?>
<?php if (!$configured): ?>
<div style="background:var(--color-bg);border:1px solid var(--gray-200);border-radius:0.25rem;padding:2rem;text-align:center;color:var(--gray-500);">
<?= $this->t('<span style="font-size:2.5rem;display:block;margin-bottom:.75rem">⚙</span> <strong>UniFi noch nicht konfiguriert.</strong><br> Bitte zuerst die <a href="/admin/settings/unifi" style="color:var(--color-primary);">Admin-Einstellungen</a> ausfüllen.') ?>
</div>
<?php else: ?>
<?php
// Stale-Hinweis: Wenn die Daten deutlich älter sind als ein paar Cron-Läufe,
// läuft der Hintergrund-Sync vermutlich nicht.
$lastTs = $lastRefresh ? strtotime($lastRefresh) : null;
if ($lastTs !== false && $lastTs !== null && (time() - $lastTs) > 3600):
?>
<div style="background:var(--amber-100);border:1px solid var(--amber-200);color:var(--amber-800);border-radius:.25rem;padding:.75rem 1rem;margin-bottom:1rem;font-size:.85rem;">
<?= $this->t('⚠ Die UniFi-Daten sind <strong>{alter}</strong> alt. Läuft der Hintergrund-Sync (<code>scheduler.php</code> / Task „UniFi Daten synchronisieren")? Du kannst auch manuell auf <strong>↻</strong> klicken.', ['alter' => $this->e(unifiAgo($lastRefresh))]) ?>
</div>
<?php endif; ?>
<!-- Site-Karten -->
<div id="site-groups-container">
<?php if (empty($siteGroups)): ?>
<div class="empty-state">
<span class="empty-icon">📡</span>
<strong><?= $this->te('Keine Geräte gecacht') ?></strong><br>
<?= $this->te('Klicke „Jetzt aktualisieren" um Daten abzurufen.') ?>
</div>
<?php else: ?>
<?php foreach ($siteGroups as $siteId => $sg):
$siteOnline = count(array_filter($sg['devices'], fn($d) => (int)$d['state'] === 1));
$siteOffline = count($sg['devices']) - $siteOnline;
$siteFetched = max(array_map(fn($d) => $d['fetched_at'] ?? '', $sg['devices']) ?: ['']);
?>
<div class="unifi-site-card" data-site="<?= htmlspecialchars($siteId) ?>">
<div class="unifi-site-header">
<span style="font-size:1.2rem">🌐</span>
<span class="unifi-site-name"><?= htmlspecialchars($sg['site_name'] ?: $siteId) ?></span>
<div class="unifi-site-badges">
<span class="status-pill status-pill--success">✓ <?= $this->te('{anzahl} online', ['anzahl' => $siteOnline]) ?></span>
<?php if ($siteOffline > 0): ?>
<span class="status-pill status-pill--danger">⚠ <?= $this->te('{anzahl} offline', ['anzahl' => $siteOffline]) ?></span>
<?php endif; ?>
<span class="status-pill status-pill--neutral"><?= $this->te('{anzahl} Geräte', ['anzahl' => count($sg['devices'])]) ?></span>
<?php if ($siteFetched): ?>
<span class="status-pill status-pill--neutral" title="<?= $this->te('Letzter Abruf') ?>">🕑 <?= htmlspecialchars(unifiAgo($siteFetched)) ?></span>
<?php endif; ?>
</div>
</div>
<table class="data-table">
<thead>
<tr>
<th scope="col"><?= $this->te('Status') ?></th>
<th scope="col"><?= $this->te('Name') ?></th>
<th scope="col"><?= $this->te('Typ') ?></th>
<th scope="col"><?= $this->te('Modell') ?></th>
<th scope="col"><?= $this->te('Inventar') ?></th>
<th scope="col"><?= $this->te('Rack') ?></th>
<th scope="col">IP</th>
<th scope="col">MAC</th>
<th scope="col"><?= $this->te('Firmware') ?></th>
<th scope="col"><?= $this->te('Uptime') ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($sg['devices'] as $d):
$dtype = $d['type'];
$dtypeGroup = in_array($dtype, ['ugw','udm','uxg'], true) ? 'gw' : $dtype;
?>
<tr data-device-id="<?= htmlspecialchars($d['device_id']) ?>" data-type="<?= htmlspecialchars($dtypeGroup) ?>">
<td>
<?= unifiStateBadge((int)$d['state']) ?>
<?php if (!empty($d['state_since'])): ?><div style="font-size:.66rem;color:var(--gray-400)"><?= $this->te('seit {dauer}', ['dauer' => unifiSince($d['state_since'])]) ?></div><?php endif; ?>
</td>
<td>
<strong><?= htmlspecialchars($d['name'] ?: '—') ?></strong>
<?php if (!empty($d['port_count'])): ?><br><a class="uf-ports-link" data-mac="<?= htmlspecialchars($d['mac']) ?>" data-name="<?= htmlspecialchars($d['name'] ?: $d['mac']) ?>">🔌 <?= (int)$d['port_count'] ?> Ports</a><?php endif; ?>
</td>
<td><?= unifiTypeBadge($d['type']) ?></td>
<td style="color:var(--gray-500)"><?= htmlspecialchars($d['model'] ?: '–') ?></td>
<td>
<?php if (!empty($d['asset_id'])): ?>
<a href="/inventory/<?= (int)$d['asset_id'] ?>" class="table-link" title="<?= $this->te('Verknüpftes Inventar-Gerät') ?>"><?= htmlspecialchars($d['asset_inv'] ?: ('#' . $d['asset_id'])) ?></a>
<?php else: ?>
<span style="color:var(--gray-400)">–</span>
<?php endif; ?>
</td>
<td>
<?php if (!empty($d['rack_id'])): ?>
<a href="/infrastructure/racks/<?= (int)$d['rack_id'] ?>" class="table-link" title="<?= $this->te('Eingebaut im Rack') ?>"><?= htmlspecialchars($d['rack_name'] ?: ('#' . $d['rack_id'])) ?></a>
<?php else: ?>
<span style="color:var(--gray-400)">–</span>
<?php endif; ?>
</td>
<td><code style="font-size:.8rem"><?= htmlspecialchars($d['ip'] ?: '–') ?></code></td>
<td><code style="font-size:.78rem;color:var(--gray-400)"><?= htmlspecialchars($d['mac'] ?: '–') ?></code></td>
<td style="font-size:.78rem;color:var(--gray-500)">
<?= htmlspecialchars($d['firmware'] ?: '–') ?>
<?php if (!empty($d['upgradable'])): ?> <span class="status-pill status-pill--warning" title="<?= $this->te('Firmware-Update verfügbar') ?>"><?= $this->te('Update') ?></span><?php endif; ?>
</td>
<td style="font-size:.78rem;color:var(--gray-500)"><?= unifiUptime(isset($d['uptime']) && $d['uptime'] !== null ? (int)$d['uptime'] : null) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php $siteClients = $clientsBySite[$siteId] ?? []; ?>
<details class="unifi-clients">
<summary><?= $this->te('📱 Verbundene Clients') ?> <span class="unifi-clients-count">(<?= count($siteClients) ?>)</span></summary>
<table class="data-table">
<thead><tr><th scope="col"><?= $this->te('Verbindung') ?></th><th scope="col"><?= $this->te('Name') ?></th><th scope="col">IP</th><th scope="col">MAC</th><th scope="col"><?= $this->te('Netzwerk') ?></th></tr></thead>
<tbody><?= unifiClientRows($siteClients) ?></tbody>
</table>
</details>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div><!-- /site-groups-container -->
<?php endif; // configured ?>
</div><!-- /page-body -->
</div><!-- /unifi-wrap -->
<!-- Toast-Container -->
<div class="unifi-toast" id="toast-container"></div>
<!-- Sync-Vorschau-Modal -->
<div class="modal-overlay uf-modal-backdrop" id="uf-modal">
<div class="modal-card" role="dialog" aria-modal="true">
<div class="modal-header"><h2 id="uf-modal-title"><?= $this->te('Vorschau') ?></h2></div>
<div class="modal-body" id="uf-modal-body"></div>
<div class="modal-footer">
<button type="button" class="btn btn-default" id="uf-modal-cancel"><?= $this->te('Abbrechen') ?></button>
<button type="button" class="btn btn-primary" id="uf-modal-ok"><?= $this->te('Übernehmen') ?></button>
</div>
</div>
</div>
<!-- Port-Detail-Modal -->
<div class="modal-overlay uf-modal-backdrop" id="uf-ports-modal">
<div class="modal-card" role="dialog" aria-modal="true">
<div class="modal-header"><h2 id="uf-ports-title"><?= $this->te('Ports') ?></h2></div>
<div class="modal-body" id="uf-ports-body"></div>
<div class="modal-footer">
<button type="button" class="btn btn-default" id="uf-ports-close"><?= $this->te('Schließen') ?></button>
</div>
</div>
</div>
<input type="hidden" id="unifi-csrf" value="<?= $this->csrf() ?>">
<script>
(function() {
var csrf = document.getElementById('unifi-csrf')?.value || '';
var REFRESH_INTERVAL = 60;
var countdownTimer = null;
var secondsLeft = REFRESH_INTERVAL;
var activeTypeFilter = 'all';
// Standard: nur die erste Site anzeigen statt aller Sites zugleich
var activeSiteFilter = <?= json_encode($defaultSiteId !== '' ? $defaultSiteId : 'all') ?>;
var clientsBySite = <?= json_encode($clientsBySite ?? [], JSON_UNESCAPED_UNICODE) ?>;
var portsByDevice = <?= json_encode($portsByDevice ?? [], JSON_UNESCAPED_UNICODE) ?>;
function showToast(msg, type) {
var c = document.getElementById('toast-container');
var el = document.createElement('div');
el.className = 'unifi-toast-item ' + (type === 'ok' ? 'ok' : 'err');
el.textContent = msg;
c.appendChild(el);
setTimeout(function() { el.remove(); }, 4000);
}
function applyFilters() {
document.querySelectorAll('.unifi-site-card').forEach(function(card) {
var site = card.getAttribute('data-site');
var siteVisible = (activeSiteFilter === 'all' || site === activeSiteFilter);
card.style.display = siteVisible ? '' : 'none';
if (!siteVisible) return;
// Zeilenfilter nach Typ
var visibleRows = 0;
card.querySelectorAll('tbody tr').forEach(function(row) {
var t = row.getAttribute('data-type');
var show = (activeTypeFilter === 'all' || t === activeTypeFilter);
row.style.display = show ? '' : 'none';
if (show) visibleRows++;
});
});
}
window.setTypeFilter = function(type) {
activeTypeFilter = type;
var sel = document.getElementById('unifi-type-select');
if (sel && sel.value !== type) sel.value = type;
applyFilters();
};
// Searchable Dropdown: Mechanik liegt in /assets/js/ssd.js.
// Hier nur die seitenspezifische Reaktion auf eine Auswahl.
document.addEventListener('ssd:select', function(e) {
if (!e.detail || e.detail.id !== 'site-ssd') return;
activeSiteFilter = e.detail.value;
applyFilters();
});
// Site-Auswahl über data-Attribute statt Inline-onclick mit interpoliertem
// Namen: Der Site-Name wird beim Klick client-seitig escaped, bevor er als
// Label gesetzt wird – so kann ein Name mit Sonderzeichen kein Markup einschleusen.
var siteSsdEl = document.getElementById('site-ssd');
if (siteSsdEl) {
siteSsdEl.addEventListener('click', function(e) {
var item = e.target.closest('.ssd__item');
if (!item || !siteSsdEl.contains(item)) return;
var val = item.getAttribute('data-value');
var name = item.getAttribute('data-name') || val;
window.ssdSelect('site-ssd', val, '🌐 ' + escH(name));
});
}
// Gemeinsame Auswertung der Antwort (von Live-Refresh wie Cache-Reload).
function applyData(d, opts) {
if (!d.ok) {
if (opts && opts.toastOnError) showToast('Fehler: ' + (d.error || 'Unbekannt'), 'err');
return;
}
if (d.lastRefresh) {
var ts = new Date(d.lastRefresh.replace(' ', 'T'));
var lbl = document.getElementById('last-refresh-label');
if (lbl) lbl.textContent = 'Aktualisiert: ' + ts.toLocaleString('de-DE');
}
if (d.clientsBySite !== undefined) clientsBySite = d.clientsBySite;
if (d.portsByDevice !== undefined) portsByDevice = d.portsByDevice;
if (d.siteGroups !== undefined) updateStats(d.siteGroups);
}
// Manueller Live-Sync (Button) – ruft den UniFi-Controller direkt ab.
function doRefresh() {
var btn = document.getElementById('btn-refresh');
if (btn) { btn.disabled = true; btn.innerHTML = '⏳'; }
resetCountdown();
fetch('/unifi/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest'
},
body: '_csrf=' + encodeURIComponent(csrf)
})
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.ok) {
showToast(d.message || 'Aktualisiert', 'ok');
applyData(d);
} else {
showToast('Fehler: ' + (d.error || 'Unbekannt'), 'err');
}
})
.catch(function(e) { showToast('Netzwerkfehler: ' + e.message, 'err'); })
.finally(function() {
if (btn) { btn.disabled = false; btn.innerHTML = '↻'; }
});
}
window.doRefresh = doRefresh;
// Auto-Refresh: lädt NUR den gecachten Stand aus der DB (kein Live-Abruf,
// schont den Controller). Der Cron hält den Cache aktuell.
function reloadCached() {
fetch('/unifi/cached', { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function(r) { return r.json(); })
.then(function(d) { applyData(d); })
.catch(function() { /* still – Auto-Reload soll nicht stören */ });
}
// Modal-Helfer (Promise-basiert)
function ufModal(title, bodyHtml, okLabel) {
return new Promise(function(resolve) {
var m = document.getElementById('uf-modal');
var ok = document.getElementById('uf-modal-ok');
var cancel = document.getElementById('uf-modal-cancel');
document.getElementById('uf-modal-title').textContent = title;
document.getElementById('uf-modal-body').innerHTML = bodyHtml;
ok.textContent = okLabel || 'Übernehmen';
m.classList.add('open');
function cleanup(res) {
m.classList.remove('open');
ok.removeEventListener('click', onOk);
cancel.removeEventListener('click', onCancel);
m.removeEventListener('click', onBg);
resolve(res);
}
function onOk() { cleanup(true); }
function onCancel() { cleanup(false); }
function onBg(e) { if (e.target === m) cleanup(false); }
ok.addEventListener('click', onOk);
cancel.addEventListener('click', onCancel);
m.addEventListener('click', onBg);
});
}
function syncPreviewHtml(type, d) {
var html = '<div>' + escH(d.message || '') + '</div>';
if (d.samples && d.samples.length) {
var rows = d.samples.map(function(s) {
var act = s.action === 'create'
? '<span class="uf-prev-create">+ neu</span>'
: (s.action === 'takeover' || s.action === 'merge')
? '<span class="uf-prev-update" title="UniFi übernimmt die belegte Adresse">↹ Übernahme</span>'
: (s.action === 'conflict')
? '<span style="color:#dc2626;font-weight:600" title="Fremde Belegung – nicht überschrieben">⚠ Konflikt</span>'
: '<span class="uf-prev-update">↻ Update</span>';
if (type === 'ipam') {
return '<tr><td>' + act + '</td><td>' + escH(s.ip) + '</td><td>' + escH(s.name) + '</td><td>' + escH(s.subnet) + '</td></tr>';
}
return '<tr><td>' + act + '</td><td>' + escH(s.name) + '</td><td>' + escH(s.subnet) + '</td><td>VLAN ' + escH(s.vlan) + '</td></tr>';
}).join('');
var head = type === 'ipam'
? '<th></th><th>IP</th><th>Name</th><th>Subnetz</th>'
: '<th></th><th>Name</th><th>Subnetz</th><th>VLAN</th>';
html += '<table class="uf-prev-table"><thead><tr>' + head + '</tr></thead><tbody>' + rows + '</tbody></table>';
if (d.samples.length >= 100) {
html += '<div style="color:#9ca3af;margin-top:.4rem;font-size:.78rem;">… nur die ersten 100 Einträge angezeigt.</div>';
}
}
return html;
}
window.doSync = function(type) {
var url = type === 'ipam' ? '/unifi/sync-ipam' : '/unifi/sync-vlans';
var label = type === 'ipam' ? 'IPAM-Sync' : 'VLAN-Sync';
// 1) Vorschau (preview=1) – schreibt nichts
fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: '_csrf=' + encodeURIComponent(csrf) + '&preview=1'
})
.then(function(r) { return r.json(); })
.then(function(d) {
if (!d.ok) { showToast(label + ' Fehler: ' + (d.error || ''), 'err'); return; }
if ((d.created + d.updated) === 0) {
var extra = (d.conflicts > 0) ? ' (' + d.conflicts + ' fremde Belegung(en) übersprungen)' : '';
showToast(label + ': nichts zu tun' + extra, 'ok');
return;
}
ufModal(label + ' – Vorschau', syncPreviewHtml(type, d), 'Übernehmen').then(function(go) {
if (!go) return;
// 2) Tatsächlich ausführen
fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: '_csrf=' + encodeURIComponent(csrf)
})
.then(function(r) { return r.json(); })
.then(function(d2) {
showToast(label + ': ' + (d2.ok ? d2.message : ('Fehler: ' + (d2.error || ''))), d2.ok ? 'ok' : 'err');
})
.catch(function(e) { showToast('Fehler: ' + e.message, 'err'); });
});
})
.catch(function(e) { showToast('Fehler: ' + e.message, 'err'); });
};
function escH(s) {
return String(s == null ? '' : s)
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
.replace(/"/g,'"').replace(/'/g,''');
}
// MAC normalisieren (Kleinbuchstaben, ohne Trennzeichen) – Schlüssel für portsByDevice
function normMac(m) { return String(m || '').toLowerCase().replace(/[^0-9a-f]/g, ''); }
// Geschwindigkeit (Mbit/s) menschenlesbar
function speedFmt(mbps) {
mbps = parseInt(mbps) || 0;
if (mbps <= 0) return '';
if (mbps >= 1000) {
var g = mbps / 1000;
return (Number.isInteger(g) ? g : g.toFixed(1)) + ' Gbit/s';
}
return mbps + ' Mbit/s';
}
// Port-Detail-Modal öffnen
function openPorts(mac, name) {
var ports = portsByDevice[normMac(mac)] || [];
ports = ports.slice().sort(function(a, b){ return (parseInt(a.port_idx)||0) - (parseInt(b.port_idx)||0); });
var active = 0, poeW = 0;
var rows = ports.map(function(p) {
var up = parseInt(p.up) === 1;
if (up) active++;
var pw = parseFloat(p.poe_power) || 0;
poeW += pw;
var status = up
? '<span class="uf-port-up">● ' + escH(speedFmt(p.speed) || 'aktiv') + (parseInt(p.full_duplex) === 1 ? '' : ' HD') + '</span>'
: '<span class="uf-port-down">○ aus</span>';
var poe;
if (parseInt(p.port_poe) !== 1) {
poe = '<span class="uf-poe-off">–</span>';
} else if (parseInt(p.poe_enable) === 1) {
poe = '<span class="uf-poe-on">⚡ ' + escH(p.poe_mode || 'an') + (pw > 0 ? ' · ' + pw.toFixed(1) + ' W' : '') + '</span>';
} else {
poe = '<span class="uf-poe-off">aus</span>';
}
var conn = p.connected
? '<span class="' + (p.connected_kind === 'device' ? 'uf-port-conn-device' : '') + '">' + escH(p.connected) + '</span>'
: '<span style="color:#9ca3af">–</span>';
var label = 'Port ' + escH(p.port_idx) + (parseInt(p.is_uplink) === 1 ? ' <span class="status-pill status-pill--neutral">Uplink</span>' : '');
return '<tr><td>' + label + '</td><td>' + escH(p.name || '') + '</td><td>' + status + '</td><td>' + poe + '</td><td>' + conn + '</td></tr>';
}).join('');
if (!rows) rows = '<tr><td colspan="5" style="text-align:center;color:#9ca3af;padding:.75rem">Keine Ports</td></tr>';
var summary = active + ' von ' + ports.length + ' Ports aktiv';
if (poeW > 0) summary += ' · PoE-Last ' + poeW.toFixed(1) + ' W';
document.getElementById('uf-ports-title').textContent = 'Ports – ' + (name || mac);
document.getElementById('uf-ports-body').innerHTML =
'<div class="uf-ports-summary">' + summary + '</div>'
+ '<table class="uf-port-table"><thead><tr>'
+ '<th>Port</th><th>Name</th><th>Status</th><th>PoE</th><th>Verbunden</th>'
+ '</tr></thead><tbody>' + rows + '</tbody></table>';
document.getElementById('uf-ports-modal').classList.add('open');
}
function closePorts() { document.getElementById('uf-ports-modal').classList.remove('open'); }
function typeBadge(t) {
if (t === 'uap') return '<span class="status-pill status-pill--info">📡 AP</span>';
if (t === 'usw') return '<span class="status-pill status-pill--success">🔗 Switch</span>';
if (t === 'ugw' || t === 'udm' || t === 'uxg') return '<span class="status-pill status-pill--purple">🔍 Gateway</span>';
return '<span class="status-pill status-pill--neutral">' + escH((t || '').toUpperCase()) + '</span>';
}
function stateBadge(s) {
var map = {
1: ['online','Online'], 0: ['offline','Offline'], 2: ['pending','Ausstehend'],
4: ['pending','Update läuft'], 5: ['pending','Provisionierung'],
6: ['offline','Kein Heartbeat'], 7: ['pending','Adoptierung'], 9: ['offline','Inform-Fehler']
};
var v = map[s] || ['pending', 'Status ' + s];
var pillCls = { online: 'status-pill--success', offline: 'status-pill--danger', pending: 'status-pill--caution' }[v[0]];
var dotCls = { online: 'status-dot--success status-dot--pulse', offline: 'status-dot--danger', pending: 'status-dot--warning' }[v[0]];
return '<span class="status-dot ' + dotCls + '"></span> <span class="status-pill ' + pillCls + '">' + escH(v[1]) + '</span>';
}
function clientsPanel(clients) {
clients = clients || [];
var rows;
if (clients.length === 0) {
rows = '<tr><td colspan="5" style="text-align:center;color:#9ca3af;padding:.75rem">Keine verbundenen Clients</td></tr>';
} else {
rows = clients.map(function(c) {
var wired = parseInt(c.is_wired) === 1;
var conn = wired
? '<span class="status-pill status-pill--success">🔗 Kabel' + (c.uplink_port ? ' P' + parseInt(c.uplink_port) : '') + '</span>'
: '<span class="status-pill status-pill--info">📶 WLAN' + (c.signal != null && c.signal !== '' ? ' ' + parseInt(c.signal) + 'dBm' : '') + '</span>';
return '<tr><td>' + conn + '</td>'
+ '<td><strong>' + escH(c.hostname || '—') + '</strong></td>'
+ '<td><code style="font-size:.8rem">' + escH(c.ip || '–') + '</code></td>'
+ '<td><code style="font-size:.78rem;color:#9ca3af">' + escH(c.mac || '–') + '</code></td>'
+ '<td style="font-size:.78rem;color:#6b7280">' + escH(c.network || '–') + '</td></tr>';
}).join('');
}
return '<details class="unifi-clients">'
+ '<summary>📱 Verbundene Clients <span class="unifi-clients-count">(' + clients.length + ')</span></summary>'
+ '<table class="data-table"><thead><tr><th>Verbindung</th><th>Name</th><th>IP</th><th>MAC</th><th>Netzwerk</th></tr></thead>'
+ '<tbody>' + rows + '</tbody></table></details>';
}
function agoFmt(dt) {
if (!dt) return '';
var ts = new Date(String(dt).replace(' ', 'T')).getTime();
if (isNaN(ts)) return '';
var s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
if (s < 60) return 'vor ' + s + 's';
if (s < 3600) return 'vor ' + Math.floor(s/60) + ' Min';
if (s < 86400) return 'vor ' + Math.floor(s/3600) + ' Std';
return 'vor ' + Math.floor(s/86400) + ' Tg';
}
function sinceFmt(dt) {
if (!dt) return '';
var ts = new Date(String(dt).replace(' ', 'T')).getTime();
if (isNaN(ts)) return '';
var s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
if (s < 60) return 'gerade eben';
if (s < 3600) return Math.floor(s/60) + ' min';
if (s < 86400) return Math.floor(s/3600) + 'h';
return Math.floor(s/86400) + 'd';
}
function uptimeFmt(secs) {
if (!secs || secs <= 0) return '–';
var d = Math.floor(secs / 86400);
var h = Math.floor((secs % 86400) / 3600);
var m = Math.floor((secs % 3600) / 60);
if (d > 0) return d + 'd ' + h + 'h';
if (h > 0) return h + 'h ' + m + 'm';
return m + ' min';
}
function renderSiteGroups(siteGroups) {
var container = document.getElementById('site-groups-container');
if (!container) return;
var siteIds = Object.keys(siteGroups || {});
if (siteIds.length === 0) {
container.innerHTML = '<div class="empty-state">'
+ '<span class="empty-icon">📡</span>'
+ '<strong>Keine Geräte gecacht</strong><br>'
+ 'Klicke „Jetzt aktualisieren" um Daten abzurufen.'
+ '</div>';
return;
}
var html = '';
siteIds.forEach(function(sid) {
var sg = siteGroups[sid];
var devices = sg.devices || [];
var siteOnline = devices.filter(function(d){ return parseInt(d.state) === 1; }).length;
var siteOffline = devices.length - siteOnline;
var siteName = sg.site_name || sid;
var siteFetched = devices.reduce(function(m, d){ return (d.fetched_at && d.fetched_at > m) ? d.fetched_at : m; }, '');
var rows = devices.map(function(d) {
var t = d.type || 'other';
var grp = (t === 'ugw' || t === 'udm' || t === 'uxg') ? 'gw' : t;
var s = parseInt(d.state);
return '<tr data-device-id="' + escH(d.device_id) + '" data-type="' + escH(grp) + '">'
+ '<td>' + stateBadge(s) + (d.state_since ? '<div style="font-size:.66rem;color:#9ca3af">seit ' + escH(sinceFmt(d.state_since)) + '</div>' : '') + '</td>'
+ '<td><strong>' + escH(d.name || '—') + '</strong>' + (parseInt(d.port_count) > 0 ? '<br><a class="uf-ports-link" data-mac="' + escH(d.mac) + '" data-name="' + escH(d.name || d.mac) + '">🔌 ' + parseInt(d.port_count) + ' Ports</a>' : '') + '</td>'
+ '<td>' + typeBadge(t) + '</td>'
+ '<td style="color:#6b7280">' + escH(d.model || '–') + '</td>'
+ '<td>' + (d.asset_id ? '<a href="/inventory/' + escH(d.asset_id) + '" class="table-link">' + escH(d.asset_inv || ('#' + d.asset_id)) + '</a>' : '<span style="color:#9ca3af">–</span>') + '</td>'
+ '<td>' + (d.rack_id ? '<a href="/infrastructure/racks/' + escH(d.rack_id) + '" class="table-link">' + escH(d.rack_name || ('#' + d.rack_id)) + '</a>' : '<span style="color:#9ca3af">–</span>') + '</td>'
+ '<td><code style="font-size:.8rem">' + escH(d.ip || '–') + '</code></td>'
+ '<td><code style="font-size:.78rem;color:#9ca3af">' + escH(d.mac || '–') + '</code></td>'
+ '<td style="font-size:.78rem;color:#6b7280">' + escH(d.firmware || '–') + (d.upgradable == 1 ? ' <span class="status-pill status-pill--warning" title="Firmware-Update verfügbar">Update</span>' : '') + '</td>'
+ '<td style="font-size:.78rem;color:#6b7280">' + uptimeFmt(parseInt(d.uptime) || 0) + '</td>'
+ '</tr>';
}).join('');
html += '<div class="unifi-site-card" data-site="' + escH(sid) + '">'
+ '<div class="unifi-site-header">'
+ '<span style="font-size:1.2rem">🌐</span>'
+ '<span class="unifi-site-name">' + escH(siteName) + '</span>'
+ '<div class="unifi-site-badges">'
+ '<span class="status-pill status-pill--success">✓ ' + siteOnline + ' online</span>'
+ (siteOffline > 0 ? '<span class="status-pill status-pill--danger">⚠ ' + siteOffline + ' offline</span>' : '')
+ '<span class="status-pill status-pill--neutral">' + devices.length + ' Geräte</span>'
+ (siteFetched ? '<span class="status-pill status-pill--neutral" title="Letzter Abruf">🕑 ' + escH(agoFmt(siteFetched)) + '</span>' : '')
+ '</div></div>'
+ '<table class="data-table">'
+ '<thead><tr><th>Status</th><th>Name</th><th>Typ</th><th>Modell</th><th>Inventar</th><th>Rack</th><th>IP</th><th>MAC</th><th>Firmware</th><th>Uptime</th></tr></thead>'
+ '<tbody>' + (rows || '<tr><td colspan="10" style="text-align:center;color:#9ca3af;padding:1rem">Keine Geräte</td></tr>') + '</tbody>'
+ '</table>'
+ clientsPanel(clientsBySite[sid])
+ '</div>';
});
container.innerHTML = html;
}
function updateStats(siteGroups) {
var total = 0, online = 0, offline = 0, ap = 0, sw = 0;
var sids = Object.keys(siteGroups || {});
sids.forEach(function(sid) {
(siteGroups[sid].devices || []).forEach(function(dev) {
total++;
if (parseInt(dev.state) === 1) online++; else offline++;
if (dev.type === 'uap') ap++;
if (dev.type === 'usw') sw++;
});
});
function set(id, val) { var el = document.getElementById(id); if (el) el.textContent = val; }
set('stat-total', total);
set('stat-online', online);
set('stat-offline', offline);
set('stat-ap', ap);
set('stat-sw', sw);
// Komplettes DOM-Rebuild der Site-Gruppen, damit gelöschte/ausstehende
// Geräte verschwinden und neue erscheinen.
renderSiteGroups(siteGroups);
// Site-Dropdown-Items synchronisieren (Counts + ggf. neue Sites)
rebuildSiteDropdown(siteGroups);
// Filter wieder anwenden (Site-/Typ-Filter beibehalten)
applyFilters();
}
function rebuildSiteDropdown(siteGroups) {
var ssd = document.getElementById('site-ssd');
if (!ssd) return;
var list = ssd.querySelector('.ssd__list');
if (!list) return;
var sids = Object.keys(siteGroups || {});
var multi = sids.length > 1;
var html = '';
if (multi) {
html += '<div class="ssd__item" data-value="all" data-name="Alle Sites">🌐 Alle Sites</div>';
}
sids.forEach(function(sid) {
var sg = siteGroups[sid];
var name = sg.site_name || sid;
var count = (sg.devices || []).length;
var isActive = (sid === activeSiteFilter);
html += '<div class="ssd__item' + (isActive ? ' selected' : '') + '" data-value="' + escH(sid) + '"'
+ ' data-name="' + escH(name) + '">'
+ '🌐 ' + escH(name)
+ ' <span style="color:#9ca3af;font-size:.72rem;">(' + count + ')</span>'
+ '</div>';
});
list.innerHTML = html;
// Wenn die aktuell gewählte Site nicht mehr existiert → auf erste umschalten
if (activeSiteFilter !== 'all' && sids.indexOf(activeSiteFilter) === -1) {
var fallback = sids[0] || 'all';
var fallbackName = fallback !== 'all' ? (siteGroups[fallback].site_name || fallback) : 'Alle Sites';
activeSiteFilter = fallback;
var lbl = document.getElementById('site-ssd-label');
if (lbl) lbl.innerHTML = '🌐 ' + escH(fallbackName);
}
}
function resetCountdown() { secondsLeft = REFRESH_INTERVAL; updateCountdown(); }
function updateCountdown() {
var el = document.getElementById('refresh-countdown');
if (el) el.textContent = 'Nächste Aktualisierung in ' + secondsLeft + 's';
}
function startAutoRefresh() {
if (countdownTimer) clearInterval(countdownTimer);
countdownTimer = setInterval(function() {
secondsLeft--;
updateCountdown();
if (secondsLeft <= 0) { resetCountdown(); reloadCached(); }
}, 1000);
updateCountdown();
}
// Port-Links (per Delegation, da Zeilen bei Refresh neu gebaut werden)
document.addEventListener('click', function(e) {
var link = e.target.closest('.uf-ports-link');
if (link) {
e.preventDefault();
openPorts(link.getAttribute('data-mac'), link.getAttribute('data-name'));
return;
}
var pm = document.getElementById('uf-ports-modal');
if (e.target === pm || e.target.id === 'uf-ports-close') closePorts();
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closePorts();
});
<?php if ($configured && !empty($siteGroups)): ?>
// Initial: nur die voreingestellte Site einblenden (statt aller).
applyFilters();
startAutoRefresh();
<?php elseif ($configured && $can_manage): ?>
// Cache leer + Verwaltungsrecht → einmalig live abrufen und befüllen.
doRefresh();
<?php elseif ($configured): ?>
// Nur Leserecht → gecachten Stand laden (kein Live-Abruf).
reloadCached();
<?php endif; ?>
})();
</script>