Quelltext des aktuellen Standes
<?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('🌐 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">
📍 <?= htmlspecialchars($selectedSiteName) ?> <span style="font-size:.7rem;margin-left:.2rem;">▼</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;">📍 <?= 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">📡 <?= $this->te('{geraete} Geräte | 🔌 {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('↺ Aktualisieren') ?></button>
</form>
<?php endif; ?>
<a href="/unifi" class="btn btn-default btn-sm"><?= $this->te('← 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')">✕</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') ?>">□</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;">📡</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,'&').replace(/</g,'<').replace(/>/g,'>');
}
// ── 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>