v0.2.1: show all NICs with IPs, filter storage to /volumeN only

Network
- NetworkCollector now also runs `ip -o -4 addr show` and merges the
  IPv4 address into each NetSample. Dashboard shows one line per
  interface (iface + IP + rx + tx); the active one is marked with `●`
  and is what the rx/tx braille graphs track.
- Hides loopback, docker*, veth*, br-*, virbr*, tap*, tun*, ovs-system,
  dummy*, wg*. Folds the raw `ethN` away when an `ovs_ethN` exists for
  the same NIC (Open vSwitch on Synology exposes both, only the ovs_
  one carries the IP).

Storage
- Only `/volumeN` (digits) is shown. Previously bind-mounts created by
  Container Manager under `/volume1/@appdata/.../all_shares/*` and other
  subpath mounts were listed too, each repeating the pool's usage —
  visually noisy and meaningless.

Verified against DS720+ (DSM 7.3.2) and a multi-NIC NAS running OvS +
Tailscale + Docker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jan Vetesnik 2026-06-13 18:33:48 +02:00
parent 367804f681
commit 78b1712fdd
5 changed files with 87 additions and 22 deletions

View file

@ -1 +1 @@
__version__ = "0.2.0"
__version__ = "0.2.1"

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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" }