OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

topology.php

22 KB
<?php
$this->layout('layouts/main', [
    'page_title'    => $page_title,
    'current_route' => $current_route,
    'full_width'    => true,
]);
?>
<style>
.topo-wrap   { display:flex; flex-direction:column; height:calc(100vh - 57px); overflow:hidden; }
.topo-toolbar{
    display:flex; align-items:center; gap:.75rem; flex-wrap:wrap;
    padding:.6rem 1.25rem; background:var(--color-bg); border-bottom:1px solid var(--slate-200); flex-shrink:0;
}
.topo-toolbar-title { font-weight:700; font-size:.95rem; color:var(--slate-800); margin-right:.25rem; }
.topo-stat { font-size:.8rem; color:var(--slate-500); white-space:nowrap; }
.topo-canvas-wrap { flex:1; position:relative; background:var(--color-bg-alt); overflow:hidden; }
#topoCanvas { width:100%; height:100%; }

/* Legend */
.topo-legend {
    position:absolute; bottom:1rem; left:1rem; z-index:10;
    background:rgba(255,255,255,.95); border:1px solid var(--slate-200);
    border-radius:8px; padding:.75rem 1rem; font-size:.78rem;
    box-shadow:0 2px 8px rgba(0,0,0,.08);
}
.topo-legend-item { display:flex; align-items:center; gap:.4rem; margin-bottom:.25rem; }
.topo-legend-item:last-child { margin-bottom:0; }
.topo-legend-dot { width:12px; height:12px; border-radius:50%; flex-shrink:0; }

/* Info panel */
.topo-info {
    position:absolute; top:1rem; right:1rem; z-index:10;
    width:240px; background:rgba(255,255,255,.97); border:1px solid var(--slate-200);
    border-radius:8px; padding:1rem; font-size:.82rem; display:none;
    box-shadow:0 4px 16px rgba(0,0,0,.1);
}
.topo-info.open { display:block; }
.topo-info-title { font-weight:700; color:var(--slate-800); margin-bottom:.5rem; font-size:.875rem; }
.topo-info-row { display:flex; gap:.35rem; margin-bottom:.2rem; }
.topo-info-label { color:var(--slate-400); width:70px; flex-shrink:0; }
.topo-info-val { color:var(--gray-700); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1; }
.topo-info-close { float:right; cursor:pointer; color:var(--slate-400); font-size:1rem; line-height:1; margin-left:.5rem; }

/* Controls */
.topo-controls {
    position:absolute; bottom:1rem; right:1rem; z-index:10;
    display:flex; flex-direction:column; gap:.35rem;
}
.topo-ctrl-btn {
    width:32px; height:32px; border-radius:6px;
    background:var(--color-bg); border:1px solid var(--slate-200); cursor:pointer;
    display:flex; align-items:center; justify-content:center;
    font-size:.85rem; color:var(--gray-700); box-shadow:0 1px 4px rgba(0,0,0,.06);
    transition:background .1s;
}
.topo-ctrl-btn:hover { background:var(--slate-50); }

/* Site dropdown */
.topo-site-dropdown { position:relative; flex-shrink:0; }
.topo-site-btn {
    height:30px; padding:0 .75rem; border:1px solid var(--gray-300); border-radius:4px;
    background:var(--color-bg); font-size:.82rem; cursor:pointer; white-space:nowrap;
    display:flex; align-items:center; gap:.25rem; color:var(--gray-700);
}
.topo-site-btn:hover { background:var(--slate-50); }
.topo-site-dd {
    display:none; position:absolute; top:calc(100% + 4px); left:0; z-index:200;
    background:var(--color-bg); border:1px solid var(--slate-200); border-radius:6px;
    box-shadow:0 4px 16px rgba(0,0,0,.1); min-width:200px; max-width:280px;
}
.topo-site-dd.open { display:block; }
.topo-site-search-wrap { padding:.4rem .5rem; border-bottom:1px solid var(--slate-100); }
.topo-site-search {
    width:100%; border:1px solid var(--slate-200); border-radius:4px;
    padding:.25rem .5rem; font-size:.8rem; outline:none;
}
.topo-site-search:focus { border-color:var(--blue-500); }
.topo-site-list { max-height:200px; overflow-y:auto; }
.topo-site-item {
    display:block; padding:.4rem .75rem; font-size:.82rem; color:var(--gray-700);
    text-decoration:none; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
.topo-site-item:hover { background:var(--slate-100); }
.topo-site-item.active { background:var(--blue-50); color:var(--blue-700); font-weight:600; }
.topo-site-item.hidden { display:none; }

/* Tooltip-Inhalt (HTML kommt aus dem Controller) */
.topo-tt-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:5px; }
.topo-tt-name { font-size:.82rem; color:var(--slate-900); }
.topo-tt-status { margin-left:8px; font-size:.7rem; }
.topo-tt-dot { font-size:10px; }
.topo-tt-table { font-size:.75rem; border-collapse:collapse; width:100%; }
.topo-tt-table td.k { color:var(--slate-400); padding:1px 8px 1px 0; white-space:nowrap; }
.topo-tt-table td.v { color:var(--slate-800); }

/* Filter */
.topo-filter { display:flex; align-items:center; gap:.6rem; flex-wrap:wrap; }
.topo-filter-sel { height:30px; padding:0 .5rem; border:1px solid var(--gray-300); border-radius:4px; font-size:.82rem; color:var(--gray-700); background:var(--color-bg); }
.topo-filter-chk { display:flex; align-items:center; gap:.3rem; font-size:.8rem; color:var(--slate-600); margin:0; cursor:pointer; white-space:nowrap; }
</style>

<div class="topo-wrap">
    <div class="topo-toolbar">
        <span class="topo-toolbar-title"><?= $this->te('&#127760; Netzwerk-Topologie') ?></span>

        <!-- Site-Auswahl -->
        <?php if (count($allSites) > 1): ?>
        <div class="topo-site-dropdown" id="siteDropdown">
            <button class="topo-site-btn" id="siteBtn" onclick="toggleSiteDd(event)" type="button">
                &#128205; <?= htmlspecialchars($selectedSiteName) ?> <span style="font-size:.7rem;margin-left:.2rem;">&#9660;</span>
            </button>
            <div class="topo-site-dd" id="siteDd">
                <div class="topo-site-search-wrap">
                    <input type="text" id="siteSearch" class="topo-site-search" placeholder="<?= $this->te('Suchen…') ?>"
                           oninput="filterSites(this.value)" autocomplete="off">
                </div>
                <div class="topo-site-list" id="siteList">
                    <?php foreach ($allSites as $site): ?>
                    <a href="/unifi/topology?site=<?= urlencode($site['site_id']) ?>"
                       class="topo-site-item <?= $site['site_id'] === $selectedSiteId ? 'active' : '' ?>"
                       data-name="<?= htmlspecialchars(strtolower($site['site_name'])) ?>">
                        <?= htmlspecialchars($site['site_name']) ?>
                    </a>
                    <?php endforeach; ?>
                </div>
            </div>
        </div>
        <?php else: ?>
        <span class="topo-stat" style="font-weight:600;">&#128205; <?= htmlspecialchars($selectedSiteName) ?></span>
        <?php endif; ?>

        <?php if ($lastRefresh): ?>
        <span class="topo-stat"><?= $this->te('Stand: {zeitpunkt}', ['zeitpunkt' => date('d.m.Y H:i', strtotime($lastRefresh))]) ?></span>
        <?php endif; ?>
        <span class="topo-stat">&#128225; <?= $this->te('{geraete} Geräte &nbsp;|&nbsp; &#128268; {netze} Netzwerke', ['geraete' => count($devices), 'netze' => count($networks)]) ?></span>
        <div style="flex:1;"></div>
        <?php if ($can_manage): ?>
        <form method="POST" action="/unifi/refresh" style="margin:0;">
            <input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
            <button type="submit" class="btn btn-default btn-sm"><?= $this->te('&#x21BA; Aktualisieren') ?></button>
        </form>
        <?php endif; ?>
        <a href="/unifi" class="btn btn-default btn-sm"><?= $this->te('&#8592; Geräteliste') ?></a>

        <!-- Layout-Switcher -->
        <div class="btn-group btn-group-sm">
            <button onclick="setLayout('hierarchical')" class="btn btn-default active" id="btnHier"><?= $this->te('Hierarchisch') ?></button>
            <button onclick="setLayout('force')"        class="btn btn-default" id="btnForce"><?= $this->te('Kraft') ?></button>
            <button onclick="setLayout('circular')"     class="btn btn-default" id="btnCircle"><?= $this->te('Kreisförmig') ?></button>
        </div>

        <?php if (!empty($devices)): ?>
        <!-- Filter -->
        <div class="topo-filter">
            <select id="topoTypeFilter" class="topo-filter-sel" onchange="applyTopoFilter()" title="<?= $this->te('Gerätetyp filtern') ?>">
                <option value="all"><?= $this->te('Alle Typen') ?></option>
                <option value="gw"><?= $this->te('Gateways') ?></option>
                <option value="usw"><?= $this->te('Switches') ?></option>
                <option value="uap"><?= $this->te('Access Points') ?></option>
            </select>
            <label class="topo-filter-chk"><input type="checkbox" id="topoShowNets" checked onchange="applyTopoFilter()"> <?= $this->te('Netzwerke') ?></label>
            <label class="topo-filter-chk"><input type="checkbox" id="topoOnlyOffline" onchange="applyTopoFilter()"> <?= $this->te('Nur Offline') ?></label>
        </div>
        <?php endif; ?>
    </div>

    <div class="topo-canvas-wrap">
        <div id="topoCanvas"></div>

        <!-- Legend -->
        <div class="topo-legend">
            <div style="font-weight:700;font-size:.75rem;margin-bottom:.4rem;color:var(--gray-500);text-transform:uppercase;letter-spacing:.05em;"><?= $this->te('Legende') ?></div>
            <div class="topo-legend-item"><div class="topo-legend-dot" style="background:var(--blue-700);"></div> <?= $this->te('Gateway / Router') ?></div>
            <div class="topo-legend-item"><div class="topo-legend-dot" style="background:var(--cyan-600);"></div> <?= $this->te('Switch') ?></div>
            <div class="topo-legend-item"><div class="topo-legend-dot" style="background:var(--violet-600);"></div> <?= $this->te('Access Point') ?></div>
            <div class="topo-legend-item"><div class="topo-legend-dot" style="background:var(--teal-700);"></div> <?= $this->te('VLAN / Netzwerk') ?></div>
            <div class="topo-legend-item"><div class="topo-legend-dot" style="background:var(--amber-700);"></div> <?= $this->te('Gäste-VLAN') ?></div>
            <hr style="margin:.4rem 0;border-color:var(--gray-200);">
            <div class="topo-legend-item">
                <div style="width:22px;height:2px;background:var(--gray-600);flex-shrink:0;"></div> <?= $this->te('Kabelverbindung') ?>
            </div>
            <div class="topo-legend-item">
                <div style="width:22px;height:1px;border-top:2px dashed var(--violet-600);flex-shrink:0;"></div> <?= $this->te('WLAN-Verbindung') ?>
            </div>
            <div class="topo-legend-item">
                <div style="width:22px;height:1px;border-top:2px dashed var(--gray-300);flex-shrink:0;opacity:.6;"></div> <?= $this->te('Verbindung unbekannt') ?>
            </div>
        </div>

        <!-- Info panel -->
        <div class="topo-info" id="topoInfo">
            <div class="topo-info-close" onclick="document.getElementById('topoInfo').classList.remove('open')">&#10005;</div>
            <div class="topo-info-title" id="tiTitle">–</div>
            <div id="tiRows"></div>
        </div>

        <!-- Zoom controls -->
        <div class="topo-controls">
            <button class="topo-ctrl-btn" onclick="topoNet.fit()" title="<?= $this->te('Alles anzeigen') ?>">&#x25A1;</button>
            <button class="topo-ctrl-btn" onclick="topoNet.moveTo({scale: topoNet.getScale() * 1.3})" title="<?= $this->te('Vergrößern') ?>">+</button>
            <button class="topo-ctrl-btn" onclick="topoNet.moveTo({scale: topoNet.getScale() / 1.3})" title="<?= $this->te('Verkleinern') ?>">−</button>
        </div>
    </div>
</div>

<?php if (empty($devices)): ?>
<script>
document.getElementById('topoCanvas').innerHTML =
    '<div style="text-align:center;padding:4rem;color:#94a3b8;">' +
    '<div style="font-size:2.5rem;margin-bottom:.5rem;">&#128225;</div>' +
    '<div style="font-size:.9rem;">Keine UniFi-Gerätedaten vorhanden. Bitte zuerst <a href="/unifi">synchronisieren</a>.</div>' +
    '</div>';
</script>
<?php else: ?>
<script src="<?= $this->asset('/assets/js/vis-network.min.js') ?>"></script>
<script>
var topoRawNodes = <?= json_encode(array_values($nodes), JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP) ?>;
var topoRawEdges = <?= json_encode(array_values($edges), JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP) ?>;

// ── Farben & Shapes nach Gruppe ──────────────────────────────────────────────
var groupStyles = {
    ugw:       { color:'#1d4ed8', shape:'diamond', size:28 },
    udm:       { color:'#1d4ed8', shape:'diamond', size:28 },
    uxg:       { color:'#1d4ed8', shape:'diamond', size:24 },
    usw:       { color:'#0891b2', shape:'square',  size:22 },
    uap:       { color:'#7c3aed', shape:'dot',     size:18 },
    ubb:       { color:'#b45309', shape:'triangle', size:20 },
    vlan:      { color:'#0f766e', shape:'ellipse', size:16 },
    vlan_guest:{ color:'#b45309', shape:'ellipse', size:14 },
    other:     { color:'#64748b', shape:'dot',     size:16 },
};

// Hilfsfunktion: HTML-String als DOM-Element für vis.js-Tooltip
function makeTooltipEl(html) {
    var wrap = document.createElement('div');
    wrap.style.cssText =
        'padding:10px 12px;' +
        'background:#fff;' +
        'border:1px solid #e2e8f0;' +
        'border-radius:8px;' +
        'box-shadow:0 4px 16px rgba(0,0,0,.12);' +
        'min-width:190px;max-width:260px;' +
        'pointer-events:none;' +
        'font-family:Inter,system-ui,sans-serif;' +
        'line-height:1.4;';
    wrap.innerHTML = html;
    return wrap;
}

// vis-Nodes aufbauen
var visNodes = topoRawNodes.map(function(n) {
    var gs = groupStyles[n.group] || groupStyles.other;
    var borderColor = n.online ? gs.color : '#e2e8f0';
    var fontColor   = '#1e293b';
    var opacity     = n.online ? 1 : 0.45;
    return {
        id:    n.id,
        label: n.label,
        title: makeTooltipEl(n.title),
        shape: gs.shape,
        size:  gs.size,
        color: {
            background: gs.color,
            border:     borderColor,
            highlight:  { background: gs.color, border: '#000' },
            hover:      { background: gs.color, border: '#000' }
        },
        font:  { color: fontColor, size: 11, face: 'Inter,system-ui,sans-serif',
                 strokeWidth: 2, strokeColor: '#fff' },
        opacity: opacity,
        // Store custom data for info panel
        _data: n,
    };
});

var visEdges = topoRawEdges.map(function(e, i) {
    var wireless = e.uplink_type === 'wireless';
    var unknown  = e.uplink_type === 'unknown';
    var vlan     = e.uplink_type === 'vlan';
    var edgeColor = vlan ? '#adb5bd' : (wireless ? '#7c3aed' : (unknown ? '#cbd5e1' : '#475569'));
    return {
        id:     i,
        from:   e.from,
        to:     e.to,
        dashes: wireless || unknown ? [6,4] : false,
        label:  e.label || '',
        font:   { size: 9, color: '#94a3b8', strokeWidth: 0, align: 'middle' },
        color:  { color: edgeColor, highlight:'#1d4ed8', hover:'#1d4ed8', opacity: unknown ? 0.4 : 1 },
        width:  wireless ? 1 : (vlan ? 1 : 2),
        smooth: { type:'cubicBezier', roundness:.25 },
        title:  wireless ? 'WLAN-Verbindung' : (unknown ? 'Verbindung unbekannt (kein Sync)' : (vlan ? 'VLAN' : 'Kabelverbindung')),
    };
});

// ── vis.js initialisieren ────────────────────────────────────────────────────
var container = document.getElementById('topoCanvas');
var data = {
    nodes: new vis.DataSet(visNodes),
    edges: new vis.DataSet(visEdges),
};

var hierarchicalOpts = {
    enabled:   true,
    direction: 'UD',
    sortMethod:'directed',
    nodeSpacing:120,
    levelSeparation:140,
};

var options = {
    layout:   { hierarchical: hierarchicalOpts },
    physics:  { enabled: false },
    interaction: {
        hover: true,
        tooltipDelay: 200,
        zoomView: true,
        dragView: true,
    },
    nodes: { borderWidth: 2 },
    edges: { arrows: '' },
};

var topoNet = new vis.Network(container, data, options);

// ── Layout wechseln ──────────────────────────────────────────────────────────
function setLayout(type) {
    ['btnHier','btnForce','btnCircle'].forEach(function(id) {
        document.getElementById(id).classList.remove('active');
    });
    if (type === 'hierarchical') {
        document.getElementById('btnHier').classList.add('active');
        topoNet.setOptions({ layout:{ hierarchical: hierarchicalOpts }, physics:{ enabled:false } });
    } else if (type === 'force') {
        document.getElementById('btnForce').classList.add('active');
        topoNet.setOptions({
            layout: { hierarchical: { enabled:false } },
            physics: {
                enabled: true,
                solver: 'forceAtlas2Based',
                forceAtlas2Based: { gravitationalConstant:-60, centralGravity:0.01, springLength:120 },
                stabilization: { iterations:200 },
            }
        });
    } else {
        document.getElementById('btnCircle').classList.add('active');
        topoNet.setOptions({
            layout: { hierarchical:{ enabled:false } },
            physics: {
                enabled: true,
                solver: 'repulsion',
                repulsion: { centralGravity:0.2, springLength:150, nodeDistance:180 },
                stabilization: { iterations:300 },
            }
        });
    }
    topoNet.fit();
}

// ── Klick → Info-Panel ───────────────────────────────────────────────────────
topoNet.on('click', function(params) {
    if (!params.nodes.length) return;
    var nodeId = params.nodes[0];
    var node   = data.nodes.get(nodeId);
    if (!node || !node._data) return;
    var d      = node._data;
    var info   = document.getElementById('topoInfo');
    document.getElementById('tiTitle').textContent = d.label.split('\n')[0];

    var typeMap = {
        ugw:'Gateway', udm:'Dream Machine', uxg:'Gateway',
        usw:'Switch', uap:'Access Point', ubb:'Building Bridge',
        vlan:'VLAN / Netzwerk', vlan_guest:'Gäste-VLAN',
    };
    var rows = [];
    if (d.group && typeMap[d.group]) rows.push(['Typ', typeMap[d.group]]);
    if (d.ip)          rows.push(['IP / Subnet', d.ip]);
    if (d.model)       rows.push(['Modell', d.model]);
    if (d.firmware)    rows.push(['Firmware', d.firmware]);
    if (d.uptime)      rows.push(['Laufzeit', formatUptime(d.uptime)]);
    if (d.uplink_type) rows.push(['Uplink', d.uplink_type === 'wireless' ? 'WLAN' : 'Kabel']);
    if (d.uplink_port) rows.push(['Port', d.uplink_port]);
    if (d.site)        rows.push(['Site', d.site]);
    rows.push(['Status', d.online ? '● Online' : '● Offline']);

    var html = rows.map(function(r) {
        return '<div class="topo-info-row">' +
               '<span class="topo-info-label">' + esc(r[0]) + '</span>' +
               '<span class="topo-info-val">'   + esc(r[1]) + '</span></div>';
    }).join('');
    if (d.asset_id) {
        html += '<div class="topo-info-row">' +
                '<span class="topo-info-label">Inventar</span>' +
                '<span class="topo-info-val"><a href="/inventory/' + encodeURIComponent(d.asset_id) + '">' +
                esc(d.asset_inv || ('#' + d.asset_id)) + '</a></span></div>';
    }
    document.getElementById('tiRows').innerHTML = html;
    info.classList.add('open');
});

topoNet.on('deselectNode', function() {
    document.getElementById('topoInfo').classList.remove('open');
});

function formatUptime(s) {
    var d = Math.floor(s/86400), h = Math.floor((s%86400)/3600), m = Math.floor((s%3600)/60);
    var parts = [];
    if (d) parts.push(d + 'd');
    if (h) parts.push(h + 'h');
    parts.push(m + 'min');
    return parts.join(' ');
}

function esc(s) {
    return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}

// ── Filter (Typ / Netzwerke / nur Offline) ───────────────────────────────────
window.applyTopoFilter = function() {
    var typeSel     = document.getElementById('topoTypeFilter');
    var showNetsEl  = document.getElementById('topoShowNets');
    var onlyOffEl   = document.getElementById('topoOnlyOffline');
    if (!typeSel) return;
    var type       = typeSel.value;
    var showNets   = !showNetsEl || showNetsEl.checked;
    var onlyOffline= onlyOffEl && onlyOffEl.checked;

    var hiddenIds = {};
    var nodeUpdates = visNodes.map(function(n) {
        var g = n._data.group;
        var isNet = (g === 'vlan' || g === 'vlan_guest');
        var typeGroup = (g === 'ugw' || g === 'udm' || g === 'uxg') ? 'gw' : g;
        var hidden = false;
        if (isNet) {
            hidden = !showNets;
        } else {
            if (type !== 'all' && typeGroup !== type) hidden = true;
            if (onlyOffline && n._data.online) hidden = true;
        }
        if (hidden) hiddenIds[n.id] = true;
        return { id: n.id, hidden: hidden };
    });
    data.nodes.update(nodeUpdates);

    var edgeUpdates = visEdges.map(function(e) {
        return { id: e.id, hidden: !!(hiddenIds[e.from] || hiddenIds[e.to]) };
    });
    data.edges.update(edgeUpdates);
};

// Fit after render
topoNet.once('stabilizationIterationsDone', function() { topoNet.fit(); });
setTimeout(function() { topoNet.fit(); }, 200);
</script>
<?php endif; ?>

<script>
// Site-Dropdown
function toggleSiteDd(e) {
    e.stopPropagation();
    var dd = document.getElementById('siteDd');
    if (!dd) return;
    dd.classList.toggle('open');
    if (dd.classList.contains('open')) {
        var inp = document.getElementById('siteSearch');
        if (inp) { inp.value = ''; filterSites(''); inp.focus(); }
    }
}
function filterSites(q) {
    var items = document.querySelectorAll('.topo-site-item');
    q = q.toLowerCase();
    items.forEach(function(el) {
        el.classList.toggle('hidden', q !== '' && el.dataset.name.indexOf(q) === -1);
    });
}
document.addEventListener('click', function(e) {
    var dd = document.getElementById('siteDd');
    var wrap = document.getElementById('siteDropdown');
    if (dd && wrap && !wrap.contains(e.target)) dd.classList.remove('open');
});
</script>