diff --git a/README.md b/README.md index b30b447..da8193b 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ A Proxmox/Ceph cluster lives or dies by its network, and network problems are sn **Device pages**: search by hostname, IP, MAC, model or interface from the dashboard, then open one page per host, switch, router, PDU or modem. Each interface or port is one row with its state, speed, traffic and neighbour. Expanding a row shows duplex, carrier flaps, error and drop rates, PoE, LLDP, and **SFP optical readings** (temperature, TX/RX power) with colour-coded thresholds. -**Network inspector**: chassis diagrams for each UniFi switch. Click a port for its stats, LLDP neighbour and a side-by-side switch/server path check. +**Port inspector**: each switch and router page has a front-panel diagram. Click a port for its stats, LLDP neighbour and a side-by-side switch/server path check. ![Inspector](docs/img/inspector-port.png) @@ -124,8 +124,9 @@ interface and port names, and LLDP neighbors. - **WAN** (routers): connection state, speed, latency, availability - **Connected to** (hosts): switch ports that see this host over LLDP - **Outlets** (PDUs): per-outlet state, power, and auto power-cycle setting +- **Port inspector** (switches, routers): see below - **Interfaces / Ports**: one row each with state, speed, RX/TX rate, and neighbor, filterable. - Expanding a row shows duplex, auto-negotiation, carrier changes, error and drop rates, + Expanding a host's row shows duplex, auto-negotiation, carrier changes, error and drop rates, traffic bars, PoE, LLDP detail, and the **SFP / optical panel** (vendor/PN, temperature, voltage, bias, TX/RX power, RX−TX delta) - **WAN / Fiber** (modem): the same panel as the dashboard @@ -134,9 +135,9 @@ These replaced the Link Debug page; `/links` now redirects to the dashboard. Hos sees but `config.json` doesn't list still get a page, with their state shown as unknown because they aren't alerted on. -### Inspector (`/inspector`) -Visual switch chassis diagrams. Each switch is rendered model-accurately using -layout config in the template (`SWITCH_LAYOUTS`). +### Port inspector (on switch and router device pages) +A model-accurate front panel, drawn from the layout config in `static/port-inspector.js` +(`SWITCH_LAYOUTS`). `/inspector` now redirects to the dashboard. Link to a port with `/device/#port-`. **Port block colours:** | Colour | State | @@ -147,7 +148,7 @@ layout config in the template (`SWITCH_LAYOUTS`). | Grey | Down | | White outline | Currently selected | -**Clicking a port** opens the right-side detail panel showing: +**Clicking a port**, on the panel or in the port table, opens the detail panel showing: - Link stats (status, speed, duplex, auto-neg, media type) - PoE (class, max wattage, current draw, mode) - Traffic (TX/RX rates) @@ -157,10 +158,15 @@ layout config in the template (`SWITCH_LAYOUTS`). two-column comparison of the switch port stats vs. the server NIC stats, including SFP DOM data if the server side has an SFP module -**LLDP path debug requirements:** -1. Server must run `lldpd`: `apt install lldpd && systemctl enable --now lldpd` -2. `lldpd` hostname must match the key in `data.hosts` (set via `config.json → hosts`) -3. Switch has LLDP enabled (UniFi default: on) +**Port neighbors**: UniFi's LLDP data identifies a neighbor only by MAC, so Gandalf names it by matching that MAC +against host NICs (`node_network_info` from node_exporter) and UniFi devices, then falling back to management IPs. +Servers that don't send LLDP are found through UniFi's wired client table. Uplink ports are skipped there, and +neighbors that can't be matched show their MAC. Bonded NICs share a MAC, so only one member's port may be identified. + +**Path check and diagnostics requirements:** +1. The server's node_exporter is scraped, so its NIC MACs are in Prometheus +2. The Pulse worker can SSH to the server +3. Optional: `lldpd` on the server lets diagnostics confirm the cabling from the server's side **Supported switch models** (set `SWITCH_LAYOUTS` keys to your UniFi model codes): @@ -468,16 +474,15 @@ after `ldap.cache_ttl` seconds or can be removed from `ldap.cache_dir`. - Confirm `sshpass` + SSH access from the Pulse worker to each Proxmox host - Only interfaces with physical SFP modules return DOM data (`ethtool -m`) -### Inspector: path debug section not appearing -- Requires LLDP: run `apt install lldpd && systemctl enable --now lldpd` on each server -- The LLDP `system_name` broadcast by `lldpd` must match the hostname in `config.json → hosts[].name` - - Override: `echo 'configure system hostname large1' > /etc/lldpd.d/hostname.conf && systemctl restart lldpd` -- Allow up to 2 poll cycles (240s) after installing lldpd for LLDP table to populate +### Port inspector: path check or Run Diagnostics not appearing +- These need the port's neighbor to be a host with link stats. A port showing only a MAC couldn't be matched +- Check that the server's node_exporter is scraped and reports `node_network_info` for that NIC +- Hosts missing from `config.json` are named by IP, so their ports may not match what you expect -### Inspector: switch chassis shows as flat list (no layout) -- The switch's `model` field from UniFi doesn't match any key in `SWITCH_LAYOUTS` in `inspector.html` -- Check the UniFi API: the model appears in the `link_stats` API response under `unifi_switches..model` -- Add the model key to `SWITCH_LAYOUTS` in `inspector.html` with the correct row/SFP layout +### Port inspector: front panel shows as a flat list (no layout) +- The switch's `model` field from UniFi doesn't match any key in `SWITCH_LAYOUTS` in `port-inspector.js` +- The model is shown on the device page +- Add the model key to `SWITCH_LAYOUTS` in `port-inspector.js` with the correct row/SFP layout ### Baseline re-initializing on every restart - `interface_baseline` is stored in the `monitor_state` DB table; survives restarts @@ -519,13 +524,13 @@ gandalf/ ├── static/ │ ├── style.css # Terminal aesthetic CSS (CRT scanlines, green-on-black) │ ├── app.js # Dashboard JS (auto-refresh, events, suppress modal, search, modem panel) -│ └── device.js # Device page rendering +│ ├── device.js # Device page rendering +│ └── port-inspector.js # Switch front panel, port panel, path check, diagnostics └── templates/ ├── base.html # Shared layout (header, nav, footer) ├── index.html # Dashboard page ├── device.html # Per-device page ├── _modem_panel.html # WAN / Fiber panel, shared by the dashboard and modem page - ├── inspector.html # Visual switch inspector + LLDP path debug └── suppressions.html # Suppression management page ``` @@ -540,11 +545,10 @@ gandalf/ 4. Restart monitor: `systemctl restart gandalf-monitor` 5. For SFP DOM / ethtool: ensure the host is SSH-accessible from the Pulse worker -### Adding a New Switch Layout (Inspector) +### Adding a New Switch Layout -Find the UniFi model code for the switch (it appears in the `/api/links` JSON response -under `unifi_switches..model`), then add to `SWITCH_LAYOUTS` in -`templates/inspector.html`: +Find the UniFi model code for the switch (shown on its device page), then add to `SWITCH_LAYOUTS` in +`static/port-inspector.js`: ```javascript 'MYNEWMODEL': { diff --git a/app.py b/app.py index 8d452ae..54f5997 100644 --- a/app.py +++ b/app.py @@ -261,8 +261,8 @@ def device_page(name: str): @app.route('/inspector') @require_auth def inspector(): - user = _get_user() - return render_template('inspector.html', user=user) + """The port inspector moved to each switch's device page; keep old bookmarks working.""" + return redirect(url_for('index')) @app.route('/suppressions') diff --git a/devices.py b/devices.py index 6502c1c..6346a75 100644 --- a/devices.py +++ b/devices.py @@ -112,6 +112,77 @@ def build_index(snapshot: dict, link_stats: dict, modem: Optional[dict]) -> List return sorted(devices.values(), key=lambda d: d['name'].lower()) +_MAC_RE = re.compile(r'^[0-9a-f]{2}(:[0-9a-f]{2}){5}$') + + +def _norm_mac(value: Optional[str]) -> str: + mac = (value or '').strip().lower().replace('-', ':') + return mac if _MAC_RE.match(mac) else '' + + +def resolve_lldp_neighbors( + switches: dict, + host_macs: Dict[str, tuple], + device_macs: Dict[str, str], + host_ips: Dict[str, str], +) -> None: + """Fill in LLDP neighbor names in place. + + UniFi's API reports a neighbor's MAC, port ID, and sometimes management IPs, but no + system name. Names come from what Gandalf already knows: + + host_macs: {mac: (host, interface)} from node_network_info + device_macs: {mac: device name} from UniFi + host_ips: {ip: host} + """ + for sw in (switches or {}).values(): + for port in (sw.get('ports') or {}).values(): + lldp = port.get('lldp') + if not lldp or lldp.get('system_name'): + continue + port_mac = _norm_mac(lldp.get('port_id')) + chassis_mac = _norm_mac(lldp.get('chassis_id')) + if port_mac in host_macs: + # The port ID names the exact NIC, so it also gives the interface. + lldp['system_name'], lldp['port_id'] = host_macs[port_mac] + elif chassis_mac in host_macs: + lldp['system_name'] = host_macs[chassis_mac][0] + elif chassis_mac in device_macs or port_mac in device_macs: + lldp['system_name'] = device_macs.get(chassis_mac) or device_macs[port_mac] + else: + lldp['system_name'] = next( + (host_ips[ip] for ip in lldp.get('mgmt_ips') or [] if ip in host_ips), '', + ) + + +def apply_client_locations( + switches: dict, + clients: List[dict], + host_macs: Dict[str, tuple], + device_macs: Dict[str, str], +) -> None: + """Name server neighbors from UniFi's wired client table, where LLDP gave no name. + + This finds servers that don't run lldpd. Uplink ports are skipped because they + carry traffic from devices further downstream. + """ + for client in clients or []: + mac = _norm_mac(client.get('mac')) + switch = device_macs.get(_norm_mac(client.get('sw_mac'))) + if mac not in host_macs or switch not in (switches or {}): + continue + for port in (switches[switch].get('ports') or {}).values(): + if port.get('port_idx') != client.get('sw_port') or port.get('is_uplink'): + continue + if (port.get('lldp') or {}).get('system_name'): + continue + host, iface = host_macs[mac] + port['lldp'] = { + 'system_name': host, 'port_id': iface, 'chassis_id': mac, + 'port_desc': '', 'mgmt_ips': [], 'source': 'unifi-clients', + } + + def uplinks_for_host(name: str, switches: dict) -> List[dict]: """Find the switch ports whose LLDP neighbor is this host.""" found = [] @@ -148,6 +219,10 @@ def device_detail(name: str, snapshot: dict, link_stats: dict, modem: Optional[d pass # the page renders the shared WAN / Fiber panel from /api/modem else: detail['ports'] = (switches.get(name) or {}).get('ports') or {} + # The port panel compares each port with the server on the other end + hosts = link_stats.get('hosts') or {} + neighbors = set(_port_neighbors(detail['ports'])) + detail['neighbor_hosts'] = {h: ifaces for h, ifaces in hosts.items() if h in neighbors} detail['info'] = (link_stats.get('unifi_devices') or {}).get(name) or {} detail['updated'] = link_stats.get('updated') return detail diff --git a/monitor.py b/monitor.py index 7522883..70eebb2 100644 --- a/monitor.py +++ b/monitor.py @@ -21,6 +21,7 @@ import requests from urllib3.exceptions import InsecureRequestWarning import db +import devices import modem as modem_api @@ -150,6 +151,7 @@ class UnifiClient: 'latency_ms': w.get('latency'), 'availability': w.get('availability'), } return { + 'mac': d.get('mac', ''), 'firmware': d.get('version', ''), 'uptime_s': d.get('uptime'), 'cpu_pct': number(stats.get('cpu')), @@ -174,6 +176,20 @@ class UnifiClient: ], } + def get_wired_clients(self) -> List[dict]: + """Return wired clients as {mac, sw_mac, sw_port}; empty if unavailable.""" + try: + url = f'{self.base_url}/proxy/network/api/s/{self.site_id}/stat/sta' + resp = self.session.get(url, headers=self.headers, timeout=15) + resp.raise_for_status() + return [ + {'mac': c.get('mac', ''), 'sw_mac': c.get('sw_mac', ''), 'sw_port': c.get('sw_port')} + for c in resp.json().get('data', []) if c.get('is_wired') + ] + except Exception as e: + logger.error(f'UniFi client list error: {e}') + return [] + def get_system_events(self, since_ms: int, keys: tuple) -> Optional[List[dict]]: """Return system log entries with the given keys, or None if the log is unavailable.""" url = f'{self.base_url}/proxy/network/v2/api/site/{self.site_id}/system-log/all' @@ -251,7 +267,8 @@ class UnifiClient: # Build LLDP neighbor map (keyed by port_idx) lldp_map: Dict[int, dict] = {} for entry in dev.get('lldp_table', []): - pidx = entry.get('lldp_port_idx') + # Current UniFi Network versions use local_port_idx + pidx = entry.get('local_port_idx', entry.get('lldp_port_idx')) if pidx is not None: lldp_map[int(pidx)] = { 'chassis_id': entry.get('chassis_id', ''), @@ -730,6 +747,7 @@ class LinkStatsCollector: logger.warning(f'UniFi switch port collection failed: {e}') unifi_devices = (self.unifi.get_device_details() or {}) if self.unifi else {} + self._resolve_neighbors(unifi_switches, unifi_devices, instance_map) return { 'hosts': result_hosts, @@ -738,6 +756,31 @@ class LinkStatsCollector: 'updated': datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC'), } + def _resolve_neighbors(self, switches: dict, unifi_devices: dict, instance_map: Dict[str, str]) -> None: + """Name port neighbors: UniFi's LLDP data has only MACs, and many servers send none.""" + host_macs: Dict[str, tuple] = {} + host_ips: Dict[str, str] = {} + # Bonds share their MAC with their member NICs, and LLDP runs on the NIC. + results = sorted( + self.prom.query('node_network_info'), + key=lambda r: r['metric'].get('device', '').startswith('bond'), + ) + for r in results: + metric = r['metric'] + device, mac = metric.get('device', ''), metric.get('address', '').lower() + instance = metric.get('instance', '') + if not is_physical_interface(device) or not mac or mac == '00:00:00:00:00:00': + continue + ip = instance.split(':')[0] + host = instance_map.get(instance, ip) + host_macs.setdefault(mac, (host, device)) + host_ips[ip] = host + device_macs = { + info['mac'].lower(): name for name, info in unifi_devices.items() if info.get('mac') + } + devices.resolve_lldp_neighbors(switches, host_macs, device_macs, host_ips) + devices.apply_client_locations(switches, self.unifi.get_wired_clients(), host_macs, device_macs) + def _compute_unifi_rates(self, raw: Dict[str, dict], now: float) -> Dict[str, dict]: """Compute per-port byte/error rates from delta against previous snapshot.""" dt = now - self._prev_unifi_time if self._prev_unifi_time > 0 else 0 diff --git a/scripts/demo/capture.py b/scripts/demo/capture.py index f5cd49d..13fbea9 100644 --- a/scripts/demo/capture.py +++ b/scripts/demo/capture.py @@ -88,7 +88,7 @@ async def shots(): await pg.screenshot(path=f"{OUT}/device-host.png", full_page=True) await go(pg, "/device/" + quote("Pro 24 PoE")) await pg.screenshot(path=f"{OUT}/device-switch.png", full_page=True) - await go(pg, "/inspector") + await go(pg, "/device/USW-Aggregation") await pg.screenshot(path=f"{OUT}/inspector.png") await select_port(pg, "USW-Aggregation", 4) await pg.screenshot(path=f"{OUT}/inspector-port.png", full_page=False) @@ -132,7 +132,7 @@ async def gif(): await snap(3) await pg.mouse.wheel(0, 500) await snap(2, 500) - await go(pg, "/inspector", 4000) + await go(pg, "/device/USW-Aggregation", 4000) await snap(2) await select_port(pg, "USW-Aggregation", 4) await snap(3) diff --git a/static/device.js b/static/device.js index 8fd0293..ca11a74 100644 --- a/static/device.js +++ b/static/device.js @@ -1,8 +1,8 @@ 'use strict'; -/* global DEVICE_NAME, deviceUrl, refreshModem, _toIso */ +/* global DEVICE_NAME, deviceUrl, refreshModem, _toIso, fmtRate, fmtErrors, renderPortInspector, selectPortByIdx, highlightSelectedPort */ -// Per-device page: overview, alerts, and a compact interface/port table whose -// rows expand to show everything the old Link Debug page showed. +// Per-device page: overview, alerts, and a compact interface/port table. Host +// rows expand in place; switch and router rows open the port inspector panel. const esc = s => lt.escHtml(String(s === null || s === undefined ? '' : s)); @@ -12,26 +12,11 @@ let _query = ''; let _device = null; // ── Formatting ─────────────────────────────────────────────────────── -function fmtRate(bytesPerSec) { - if (bytesPerSec === null || bytesPerSec === undefined) return '–'; - const bps = bytesPerSec * 8; - if (bps < 1e3) return bps.toFixed(0) + ' bps'; - if (bps < 1e6) return (bps / 1e3).toFixed(1) + ' Kbps'; - if (bps < 1e9) return (bps / 1e6).toFixed(2) + ' Mbps'; - return (bps / 1e9).toFixed(3) + ' Gbps'; -} - function fmtSpeed(mbps) { if (!mbps) return '–'; return mbps >= 1000 ? (mbps / 1000).toFixed(0) + ' Gbps' : mbps + ' Mbps'; } -function fmtErrors(rate) { - if (rate === null || rate === undefined) return '–'; - if (rate < 0.001) return '0 /s'; - return `${rate.toFixed(3)} /s`; -} - function fmtUnit(value, unit, digits) { return value === null || value === undefined ? '–' : `${value.toFixed(digits)} ${unit}`; } @@ -160,28 +145,14 @@ function portRows(ports) { return Object.entries(ports) .sort(([, a], [, b]) => (a.port_idx || 0) - (b.port_idx || 0)) .map(([name, d]) => { - const neighbor = d.lldp && d.lldp.system_name; return { id: name, label: name, num: d.port_idx, up: !!d.up, data: d, - neighbor: d.lldp ? { name: neighbor, port: d.lldp.port_id } : null, + neighbor: d.lldp ? { name: d.lldp.system_name, port: d.lldp.port_id, chassis: d.lldp.chassis_id } : null, sub: [d.is_uplink ? 'uplink' : '', d.media || '', d.poe_power ? `PoE ${d.poe_power.toFixed(1)} W` : ''].filter(Boolean).join(' · '), - detail: ` - - ${d.lldp ? `
LLDP: ${esc(d.lldp.system_name || '')} (${esc(d.lldp.port_id || '')})${d.lldp.chassis_id ? ` · ${esc(d.lldp.chassis_id)}` : ''}${(d.lldp.mgmt_ips || []).length ? ` · ${esc(d.lldp.mgmt_ips.join(', '))}` : ''}
` : ''} - ${trafficBars(d)}`, }; }); } -// Down alone isn't a problem: most down ports are simply unused. function rowHasProblem(r) { return !!badges(r.data); } @@ -196,14 +167,14 @@ function renderTable(rows) { if (!shown.length) return '
Nothing matches this filter
'; const isPorts = rows.some(r => r.num !== undefined); const body = shown.map(r => { - const open = _openRows.has(r.id); + const open = !isPorts && _openRows.has(r.id); const d = r.data; const neighbor = r.neighbor - ? (r.neighbor.name ? `${esc(r.neighbor.name)}` : esc(r.neighbor.port || '')) + ? (r.neighbor.name ? `${esc(r.neighbor.name)}` : esc(r.neighbor.chassis || r.neighbor.port || '')) : '–'; return ` - - + + ${r.num !== undefined ? `#${esc(r.num)} ` : ''}${esc(r.label)}
${esc(r.sub)}
${r.up ? fmtSpeed(d.speed_mbps) : '–'} @@ -212,7 +183,7 @@ function renderTable(rows) { ${isPorts ? `${neighbor}` : ''} ${badges(d) || (r.up ? 'OK' : 'down')} - ${open ? `${r.detail}` : ''}`; + ${open ? `${r.detail}` : ''}`; }).join(''); return `
@@ -272,7 +243,7 @@ function renderUplinks(d) { ${esc(u.host_interface || '–')} ${esc(u.switch)} - #${esc(u.port_idx)} ${esc(u.port)} + #${esc(u.port_idx)} ${esc(u.port)} ${fmtSpeed(u.speed_mbps)} `).join(''); return section('Connected to', `
@@ -341,27 +312,19 @@ function currentRows() { function renderInterfaces() { const wrap = document.getElementById('device-table-wrap'); - if (wrap) wrap.innerHTML = renderTable(currentRows()); + if (!wrap) return; + wrap.innerHTML = renderTable(currentRows()); + highlightSelectedPort(); } -function renderInterfaceSection(rows, title) { - if (!rows.length) return ''; - const up = rows.filter(r => r.up).length; - const problems = rows.filter(rowHasProblem).length; - const pill = (value, label) => ``; - return section(`${title} · ${up} of ${rows.length} up`, ` -
-
- -
- ${pill('all', 'All')}${pill('up', 'Up')}${pill('problems', `Errors (${problems})`)} -
-
-
-
`); +function updateInterfaceSection(rows, title) { + const section = document.getElementById('device-ports'); + section.hidden = !rows.length; + if (!rows.length) return; + document.getElementById('device-ports-title').textContent = + `${title} · ${rows.filter(r => r.up).length} of ${rows.length} up`; + document.getElementById('device-table-search').setAttribute('aria-label', `Filter ${title.toLowerCase()}`); + document.getElementById('device-error-count').textContent = rows.filter(rowHasProblem).length; } function render(d) { @@ -373,29 +336,22 @@ function render(d) { const unifi = d.source === 'unifi'; document.getElementById('device-actions').innerHTML = ` - ${d.ports && Object.keys(d.ports).length ? `⬡ Inspector` : ''} `; - // Keep focus and caret in the filter box across auto-refresh - const active = document.activeElement && document.activeElement.id === 'device-table-search'; - const caret = active ? document.activeElement.selectionStart : 0; - - const rows = currentRows(); document.getElementById('device-root').innerHTML = renderOverview(d) + renderEvents(d.events) + renderWan(d) + renderUplinks(d) + - renderOutlets(d) + - renderInterfaceSection(rows, d.ports ? 'Ports' : 'Interfaces'); + renderOutlets(d); + + const hasPorts = !!(d.ports && Object.keys(d.ports).length); + document.getElementById('device-inspector').classList.toggle('has-chassis', hasPorts); + updateInterfaceSection(currentRows(), d.ports ? 'Ports' : 'Interfaces'); + if (hasPorts) renderPortInspector(d); renderInterfaces(); - if (active) { - const box = document.getElementById('device-table-search'); - box.focus(); - box.setSelectionRange(caret, caret); - } if (d.kind === 'modem') refreshModem(); } @@ -418,6 +374,12 @@ async function loadDevice() { } document.addEventListener('click', e => { + // Switch and router rows open the shared port panel, except when following a link + const portRow = e.target.closest('tr.device-row[data-port-idx]'); + if (portRow && !e.target.closest('a')) { + selectPortByIdx(_device.name, parseInt(portRow.dataset.portIdx, 10)); + return; + } const toggle = e.target.closest('.device-row-toggle'); if (toggle) { const id = toggle.dataset.row; diff --git a/templates/inspector.html b/static/port-inspector.js similarity index 86% rename from templates/inspector.html rename to static/port-inspector.js index d72f777..c5c44f3 100644 --- a/templates/inspector.html +++ b/static/port-inspector.js @@ -1,31 +1,10 @@ -{% extends "base.html" %} -{% block title %}Inspector – GANDALF{% endblock %} +'use strict'; +/* global deviceUrl */ +/* exported renderPortInspector, fmtRate, fmtErrors */ -{% block content %} +// Port inspector for a switch or router's device page: front-panel diagram, +// port detail panel, switch/server path check, and link diagnostics. -
-
-

Network Inspector

-

- Visual switch chassis diagrams. Click a port to see detailed stats and LLDP path debug. - -

-
-
- -
-
- -
- -
- -{% endblock %} - -{% block scripts %} - -{% endblock %} diff --git a/static/style.css b/static/style.css index 3c64009..6d6ab90 100644 --- a/static/style.css +++ b/static/style.css @@ -1149,3 +1149,26 @@ a.device-link:hover { color: var(--text-accent); border-bottom-color: current /* Scroll inside the wrapper rather than squeezing every column to a few letters */ #events-table { min-width: 760px; } } + +/* ── Port inspector on the device page ────────────────────────────── */ +.device-inspector { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 14px 16px; align-items: start; } +.device-chassis { grid-column: 1; min-width: 0; } +.device-port-table { grid-column: 1; min-width: 0; } +.device-inspector .inspector-panel { grid-column: 2; grid-row: 1 / span 2; position: sticky; top: 70px; } +.device-inspector:not(.has-chassis) .inspector-panel, +.device-inspector:not(.has-chassis) .device-chassis { display: none; } +tr.device-row[data-port-idx] { cursor: pointer; } +tr.device-row.is-selected { background: var(--bg-tertiary); box-shadow: inset 3px 0 0 var(--terminal-cyan, #0ff); } +.device-chassis .switch-port-block, +.device-chassis .switch-port-block.sfp-port, +.device-chassis .switch-port-block.sfp-block { width: 50px; height: 50px; font-size: .68em; gap: 2px; } +.device-chassis .port-num { font-size: 1.2em; } +.device-chassis .port-speed { font-size: .85em; } +.device-chassis .port-lldp { font-size: .8em; max-width: 46px; margin: 0; } +@media (max-width: 900px) { + .device-inspector { grid-template-columns: minmax(0, 1fr); } + .device-inspector .inspector-panel { grid-column: 1; grid-row: auto; position: static; } + .device-inspector .inspector-panel.open { width: 100%; } + .device-inspector .inspector-panel-inner { width: 100%; max-height: none; } + .device-chassis .chassis-ear-l, .device-chassis .chassis-ear-r { display: none; } +} diff --git a/templates/base.html b/templates/base.html index 9ae9b55..f54f182 100644 --- a/templates/base.html +++ b/templates/base.html @@ -37,9 +37,6 @@ Dashboard - Inspector {% if user.groups and 'admin' in user.groups %} Dashboard - - Inspector - {% if user.groups and 'admin' in user.groups %} | [ S ] SUPPRESS | - {% elif request.endpoint in ('device_page', 'inspector') %} + {% elif request.endpoint == 'device_page' %} [ R ] REFRESH | {% endif %} @@ -255,7 +247,7 @@ - + @@ -324,7 +316,6 @@ // Command palette lt.cmdPalette.init([ { id: 'nav-dashboard', group: 'Navigate', icon: '~', label: 'Dashboard', action: function() { window.location.href = '/'; } }, - { id: 'nav-inspector', group: 'Navigate', icon: '⬡', label: 'Inspector', action: function() { window.location.href = '/inspector'; } }, { id: 'nav-suppressions', group: 'Navigate', icon: '🔕', label: 'Suppressions', action: function() { window.location.href = '/suppressions'; } }, { id: 'action-refresh', group: 'Actions', icon: '↻', label: 'Refresh Data', kbd: 'R', action: function() { lt.autoRefresh.now(); } }, { id: 'action-suppress', group: 'Actions', icon: '🔕', label: 'New Suppression', kbd: 'S', action: function() { if (typeof openSuppressModal === 'function') openSuppressModal('host','',''); else window.location.href='/suppressions'; } }, diff --git a/templates/device.html b/templates/device.html index 3d28d07..7f91795 100644 --- a/templates/device.html +++ b/templates/device.html @@ -16,10 +16,40 @@
+ + + {% include "_modem_panel.html" %} {% endblock %} {% block scripts %} + {% endblock %} diff --git a/tests/test_app.py b/tests/test_app.py index e6f24c0..d2d37f6 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -58,7 +58,19 @@ class TestPagesAndNav: html = client.get('/device/%3Cscript%3Ex', headers=AUTH).get_data(as_text=True) assert '
ShortcutAction
Ctrl / ⌘ + KCommand palette
RRefresh data (Dashboard / Device / Inspector)
RRefresh data (Dashboard / Device)
/Search devices (Dashboard)
SQuick-suppress alert (Dashboard)
*Open settings