OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

index.php

50 KB
<?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">&#128225; AP</span>',
        'usw' => '<span class="status-pill status-pill--success">&#128279; Switch</span>',
        'ugw','udm','uxg' => '<span class="status-pill status-pill--purple">&#128269; 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">&#128279; Kabel' . (!empty($c['uplink_port']) ? ' P' . (int)$c['uplink_port'] : '') . '</span>'
            : '<span class="status-pill status-pill--info">&#128246; 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('&#127914; 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'] ?? '–']) ?>
            &middot; <?= (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">&#127760; <?= htmlspecialchars($defaultSiteName ?: $defaultSiteId) ?></span>
                        <span class="ssd__arrow">&#9660;</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('&#127760; 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) ?>">
                                &#127760; <?= 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') ?>">&#8635;</button>

                <div class="unifi-toolbar-divider"></div>
                <button class="btn btn-default btn-sm" onclick="doSync('ipam')" title="<?= $this->te('IPAM-Sync') ?>"><?= $this->te('&#127760; IPAM') ?></button>
                <button class="btn btn-default btn-sm" onclick="doSync('vlans')" title="<?= $this->te('VLAN-Sync') ?>"><?= $this->te('&#128203; 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">&#9881;</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('&#9888; 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>&#8635;</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">&#128225;</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">&#127760;</span>
        <span class="unifi-site-name"><?= htmlspecialchars($sg['site_name'] ?: $siteId) ?></span>
        <div class="unifi-site-badges">
            <span class="status-pill status-pill--success">&#10003; <?= $this->te('{anzahl} online', ['anzahl' => $siteOnline]) ?></span>
            <?php if ($siteOffline > 0): ?>
            <span class="status-pill status-pill--danger">&#9888; <?= $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') ?>">&#128337; <?= 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']) ?>">&#128268; <?= (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('&#128241; 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, '&#127760; ' + 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 = '&#8635;'; }
        });
    }
    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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
            .replace(/"/g,'&quot;').replace(/'/g,'&#39;');
    }

    // 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">&#9679; ' + escH(speedFmt(p.speed) || 'aktiv') + (parseInt(p.full_duplex) === 1 ? '' : ' HD') + '</span>'
                : '<span class="uf-port-down">&#9675; 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">&#9889; ' + escH(p.poe_mode || 'an') + (pw > 0 ? ' &middot; ' + 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 += ' &middot; 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">&#128225; AP</span>';
        if (t === 'usw') return '<span class="status-pill status-pill--success">&#128279; Switch</span>';
        if (t === 'ugw' || t === 'udm' || t === 'uxg') return '<span class="status-pill status-pill--purple">&#128269; 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">&#128279; Kabel' + (c.uplink_port ? ' P' + parseInt(c.uplink_port) : '') + '</span>'
                    : '<span class="status-pill status-pill--info">&#128246; 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>&#128241; 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">&#128225;</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) + '">&#128268; ' + 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">&#127760;</span>'
                + '<span class="unifi-site-name">' + escH(siteName) + '</span>'
                + '<div class="unifi-site-badges">'
                + '<span class="status-pill status-pill--success">&#10003; ' + siteOnline + ' online</span>'
                + (siteOffline > 0 ? '<span class="status-pill status-pill--danger">&#9888; ' + 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">&#128337; ' + 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">&#127760; 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) + '">'
                + '&#127760; ' + 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 = '&#127760; ' + 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>