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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from dsmview.collectors.base import Collector
|
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)
|
@dataclass(slots=True)
|
||||||
class IfaceCounters:
|
class IfaceCounters:
|
||||||
|
|
@ -21,6 +32,7 @@ class NetSample:
|
||||||
tx_bps: float = 0.0
|
tx_bps: float = 0.0
|
||||||
rx_total: int = 0
|
rx_total: int = 0
|
||||||
tx_total: int = 0
|
tx_total: int = 0
|
||||||
|
ip: str = ""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
|
|
@ -28,16 +40,13 @@ class NetworkSnapshot:
|
||||||
samples: List[NetSample] = field(default_factory=list)
|
samples: List[NetSample] = field(default_factory=list)
|
||||||
|
|
||||||
def primary(self) -> Optional[NetSample]:
|
def primary(self) -> Optional[NetSample]:
|
||||||
# Pick the interface with the most total traffic. Falls back to
|
"""Pick the interface with the most accumulated traffic. Falls
|
||||||
# eth0-style names when no counters have accumulated yet.
|
back to the first sample if no counters have moved yet."""
|
||||||
if not self.samples:
|
if not self.samples:
|
||||||
return None
|
return None
|
||||||
with_traffic = [s for s in self.samples if (s.rx_total + s.tx_total) > 0]
|
with_traffic = [s for s in self.samples if (s.rx_total + s.tx_total) > 0]
|
||||||
if with_traffic:
|
if with_traffic:
|
||||||
return max(with_traffic, key=lambda s: s.rx_total + s.tx_total)
|
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]
|
return self.samples[0]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -49,18 +58,38 @@ class NetworkCollector(Collector[NetworkSnapshot]):
|
||||||
self._prev: Dict[str, IfaceCounters] = {}
|
self._prev: Dict[str, IfaceCounters] = {}
|
||||||
|
|
||||||
async def collect(self) -> NetworkSnapshot:
|
async def collect(self) -> NetworkSnapshot:
|
||||||
result = await self.executor.run("cat /proc/net/dev")
|
netdev, ipaddr = await self.executor.gather([
|
||||||
return self._parse(result.stdout)
|
"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()
|
snap = NetworkSnapshot()
|
||||||
now = time.monotonic()
|
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:
|
if ":" not in line:
|
||||||
continue
|
continue
|
||||||
name, _, rest = line.partition(":")
|
name, _, rest = line.partition(":")
|
||||||
iface = name.strip()
|
iface = name.strip()
|
||||||
if iface in ("lo",) or iface.startswith("docker") or iface.startswith("veth"):
|
if self._is_hidden(iface):
|
||||||
continue
|
continue
|
||||||
fields = rest.split()
|
fields = rest.split()
|
||||||
if len(fields) < 16:
|
if len(fields) < 16:
|
||||||
|
|
@ -72,12 +101,33 @@ class NetworkCollector(Collector[NetworkSnapshot]):
|
||||||
continue
|
continue
|
||||||
current = IfaceCounters(rx_bytes=rx, tx_bytes=tx, timestamp=now)
|
current = IfaceCounters(rx_bytes=rx, tx_bytes=tx, timestamp=now)
|
||||||
prev = self._prev.get(iface)
|
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:
|
if prev is not None:
|
||||||
dt = current.timestamp - prev.timestamp
|
dt = current.timestamp - prev.timestamp
|
||||||
if dt > 0:
|
if dt > 0:
|
||||||
sample.rx_bps = max(0.0, (rx - prev.rx_bytes) / dt)
|
sample.rx_bps = max(0.0, (rx - prev.rx_bytes) / dt)
|
||||||
sample.tx_bps = max(0.0, (tx - prev.tx_bytes) / dt)
|
sample.tx_bps = max(0.0, (tx - prev.tx_bytes) / dt)
|
||||||
snap.samples.append(sample)
|
candidates[iface] = sample
|
||||||
self._prev[iface] = current
|
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
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from dsmview.collectors.base import Collector
|
from dsmview.collectors.base import Collector
|
||||||
|
|
||||||
|
_VOLUME_RE = re.compile(r"^/volume\d+$")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class VolumeInfo:
|
class VolumeInfo:
|
||||||
|
|
@ -51,9 +54,10 @@ class StorageCollector(Collector[StorageSnapshot]):
|
||||||
if len(parts) < 6:
|
if len(parts) < 6:
|
||||||
continue
|
continue
|
||||||
device, size, used, avail, pct, mount = parts[:6]
|
device, size, used, avail, pct, mount = parts[:6]
|
||||||
# Only show user-facing data volumes. /, /tmp/* and friends
|
# Only show actual storage pools — /volume1, /volume2, etc.
|
||||||
# are noise on Synology — they're the DSM system partition.
|
# Container Manager bind-mounts (/volume1/@appdata/...) and
|
||||||
if not mount.startswith("/volume"):
|
# system mounts repeat the same usage and just add noise.
|
||||||
|
if not _VOLUME_RE.match(mount):
|
||||||
continue
|
continue
|
||||||
if device in ("tmpfs", "devtmpfs", "overlay", "none"):
|
if device in ("tmpfs", "devtmpfs", "overlay", "none"):
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -137,11 +137,22 @@ class DashboardTab(VerticalScroll):
|
||||||
return
|
return
|
||||||
self.net_rx_graph.push(primary.rx_bps)
|
self.net_rx_graph.push(primary.rx_bps)
|
||||||
self.net_tx_graph.push(primary.tx_bps)
|
self.net_tx_graph.push(primary.tx_bps)
|
||||||
self.net_summary.update(
|
# One line per interface so multi-NIC setups (LACP, OvS, Tailscale)
|
||||||
f" [bold]{primary.iface}[/] "
|
# are visible. The primary is highlighted because the graph
|
||||||
f"[{theme.NET_DOWN}]↓ {_fmt_bytes_per_sec(primary.rx_bps)}[/] "
|
# underneath tracks it.
|
||||||
f"[{theme.NET_UP}]↑ {_fmt_bytes_per_sec(primary.tx_bps)}[/]"
|
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
|
SERVICES_LIMIT = 30
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "dsmview"
|
name = "dsmview"
|
||||||
version = "0.2.0"
|
version = "0.2.1"
|
||||||
description = "TUI SSH monitor for Synology DSM 7.x"
|
description = "TUI SSH monitor for Synology DSM 7.x"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue