diff --git a/dsmview/__init__.py b/dsmview/__init__.py index d3ec452..3ced358 100644 --- a/dsmview/__init__.py +++ b/dsmview/__init__.py @@ -1 +1 @@ -__version__ = "0.2.0" +__version__ = "0.2.1" diff --git a/dsmview/collectors/network.py b/dsmview/collectors/network.py index bdff4bb..5311fe5 100644 --- a/dsmview/collectors/network.py +++ b/dsmview/collectors/network.py @@ -1,11 +1,22 @@ from __future__ import annotations +import re import time from dataclasses import dataclass, field from typing import Dict, List, Optional from dsmview.collectors.base import Collector +# Interface prefixes we never want to surface — virtual, internal, +# bridge, container, VPN. +_HIDE_PREFIXES = ( + "lo", "docker", "veth", "br-", "virbr", "tap", "tun", + "sit", "ovs-system", "dummy", "wg", +) +# Open vSwitch on Synology exposes the real NIC twice — as `ethN` and +# `ovs_ethN`. The ovs_ wrapper is the one with the IP, so prefer it. +_OVS_RE = re.compile(r"^ovs_(eth\d+)$") + @dataclass(slots=True) class IfaceCounters: @@ -21,6 +32,7 @@ class NetSample: tx_bps: float = 0.0 rx_total: int = 0 tx_total: int = 0 + ip: str = "" @dataclass(slots=True) @@ -28,16 +40,13 @@ class NetworkSnapshot: samples: List[NetSample] = field(default_factory=list) def primary(self) -> Optional[NetSample]: - # Pick the interface with the most total traffic. Falls back to - # eth0-style names when no counters have accumulated yet. + """Pick the interface with the most accumulated traffic. Falls + back to the first sample if no counters have moved yet.""" if not self.samples: return None with_traffic = [s for s in self.samples if (s.rx_total + s.tx_total) > 0] if with_traffic: return max(with_traffic, key=lambda s: s.rx_total + s.tx_total) - for s in self.samples: - if s.iface in ("eth0", "eth1", "ovs_eth0", "bond0"): - return s return self.samples[0] @@ -49,18 +58,38 @@ class NetworkCollector(Collector[NetworkSnapshot]): self._prev: Dict[str, IfaceCounters] = {} async def collect(self) -> NetworkSnapshot: - result = await self.executor.run("cat /proc/net/dev") - return self._parse(result.stdout) + netdev, ipaddr = await self.executor.gather([ + "cat /proc/net/dev", + "ip -o -4 addr show 2>/dev/null || true", + ]) + ips = self._parse_ip_addrs(ipaddr.stdout) + return self._merge(netdev.stdout, ips) - def _parse(self, text: str) -> NetworkSnapshot: + @staticmethod + def _parse_ip_addrs(text: str) -> Dict[str, str]: + """Map interface name → first IPv4 address.""" + out: Dict[str, str] = {} + for line in text.splitlines(): + parts = line.split() + if len(parts) < 4 or parts[2] != "inet": + continue + iface = parts[1] + cidr = parts[3] + addr = cidr.split("/", 1)[0] + out.setdefault(iface, addr) + return out + + def _merge(self, netdev_text: str, ips: Dict[str, str]) -> NetworkSnapshot: snap = NetworkSnapshot() now = time.monotonic() - for line in text.splitlines(): + # First pass: read counters for all visible non-hidden interfaces. + candidates: Dict[str, NetSample] = {} + for line in netdev_text.splitlines(): if ":" not in line: continue name, _, rest = line.partition(":") iface = name.strip() - if iface in ("lo",) or iface.startswith("docker") or iface.startswith("veth"): + if self._is_hidden(iface): continue fields = rest.split() if len(fields) < 16: @@ -72,12 +101,33 @@ class NetworkCollector(Collector[NetworkSnapshot]): continue current = IfaceCounters(rx_bytes=rx, tx_bytes=tx, timestamp=now) prev = self._prev.get(iface) - sample = NetSample(iface=iface, rx_total=rx, tx_total=tx) + sample = NetSample(iface=iface, rx_total=rx, tx_total=tx, ip=ips.get(iface, "")) if prev is not None: dt = current.timestamp - prev.timestamp if dt > 0: sample.rx_bps = max(0.0, (rx - prev.rx_bytes) / dt) sample.tx_bps = max(0.0, (tx - prev.tx_bytes) / dt) - snap.samples.append(sample) + candidates[iface] = sample self._prev[iface] = current + + # OvS hides the real NIC behind both `ethN` and `ovs_ethN`. The + # ovs_ entry is what carries the IP; drop the raw ethN twin when + # both exist so we don't show a duplicate row. + for name in list(candidates): + m = _OVS_RE.match(name) + if m and m.group(1) in candidates: + candidates.pop(m.group(1), None) + + # Sort: interfaces with an IP first, then by total traffic desc. + snap.samples = sorted( + candidates.values(), + key=lambda s: (0 if s.ip else 1, -(s.rx_total + s.tx_total), s.iface), + ) return snap + + @staticmethod + def _is_hidden(iface: str) -> bool: + for prefix in _HIDE_PREFIXES: + if iface == prefix or iface.startswith(prefix): + return True + return False diff --git a/dsmview/collectors/storage.py b/dsmview/collectors/storage.py index d575f6e..dacd4a3 100644 --- a/dsmview/collectors/storage.py +++ b/dsmview/collectors/storage.py @@ -1,10 +1,13 @@ from __future__ import annotations +import re from dataclasses import dataclass, field from typing import List from dsmview.collectors.base import Collector +_VOLUME_RE = re.compile(r"^/volume\d+$") + @dataclass(slots=True) class VolumeInfo: @@ -51,9 +54,10 @@ class StorageCollector(Collector[StorageSnapshot]): if len(parts) < 6: continue device, size, used, avail, pct, mount = parts[:6] - # Only show user-facing data volumes. /, /tmp/* and friends - # are noise on Synology — they're the DSM system partition. - if not mount.startswith("/volume"): + # Only show actual storage pools — /volume1, /volume2, etc. + # Container Manager bind-mounts (/volume1/@appdata/...) and + # system mounts repeat the same usage and just add noise. + if not _VOLUME_RE.match(mount): continue if device in ("tmpfs", "devtmpfs", "overlay", "none"): continue diff --git a/dsmview/ui/tabs/dashboard.py b/dsmview/ui/tabs/dashboard.py index e662dd7..6c904ed 100644 --- a/dsmview/ui/tabs/dashboard.py +++ b/dsmview/ui/tabs/dashboard.py @@ -137,11 +137,22 @@ class DashboardTab(VerticalScroll): return self.net_rx_graph.push(primary.rx_bps) self.net_tx_graph.push(primary.tx_bps) - self.net_summary.update( - f" [bold]{primary.iface}[/] " - f"[{theme.NET_DOWN}]↓ {_fmt_bytes_per_sec(primary.rx_bps)}[/] " - f"[{theme.NET_UP}]↑ {_fmt_bytes_per_sec(primary.tx_bps)}[/]" - ) + # One line per interface so multi-NIC setups (LACP, OvS, Tailscale) + # are visible. The primary is highlighted because the graph + # underneath tracks it. + max_name = max((len(s.iface) for s in s.samples), default=8) + max_ip = max((len(s.ip) for s in s.samples), default=0) + lines = [] + for sample in s.samples: + iface = sample.iface.ljust(max_name) + ip = (sample.ip or "—").ljust(max_ip if max_ip else 1) + tag = "[bold]●[/]" if sample is primary else " " + lines.append( + f" {tag} [bold]{iface}[/] [grey70]{ip}[/] " + f"[{theme.NET_DOWN}]↓ {_fmt_bytes_per_sec(sample.rx_bps)}[/] " + f"[{theme.NET_UP}]↑ {_fmt_bytes_per_sec(sample.tx_bps)}[/]" + ) + self.net_summary.update("\n".join(lines)) SERVICES_LIMIT = 30 diff --git a/pyproject.toml b/pyproject.toml index ce4d7e0..d45f10e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "dsmview" -version = "0.2.0" +version = "0.2.1" description = "TUI SSH monitor for Synology DSM 7.x" readme = "README.md" license = { text = "MIT" }