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:
parent
367804f681
commit
78b1712fdd
5 changed files with 87 additions and 22 deletions
|
|
@ -1 +1 @@
|
|||
__version__ = "0.2.0"
|
||||
__version__ = "0.2.1"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue