dsmview/dsmview/ui/tabs/dashboard.py
Jan Vetesnik 78b1712fdd 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>
2026-06-13 18:33:48 +02:00

196 lines
7.7 KiB
Python

from __future__ import annotations
from typing import Iterable
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical, VerticalScroll
from textual.widgets import DataTable, Static
from dsmview.collectors.disks import DisksSnapshot
from dsmview.collectors.network import NetworkSnapshot
from dsmview.collectors.services import ServiceInfo
from dsmview.collectors.storage import StorageSnapshot
from dsmview.collectors.system import SystemSnapshot
from dsmview.ui import theme
from dsmview.ui.widgets import BrailleGraph
def _fmt_bytes_per_sec(b: float) -> str:
units = ("B/s", "K/s", "M/s", "G/s")
i = 0
while b >= 1024 and i < len(units) - 1:
b /= 1024
i += 1
return f"{b:6.1f} {units[i]}"
def _fmt_uptime(s: float) -> str:
s = int(s)
days, s = divmod(s, 86400)
hours, s = divmod(s, 3600)
minutes, _ = divmod(s, 60)
if days:
return f"{days}d {hours:02d}h {minutes:02d}m"
return f"{hours:02d}h {minutes:02d}m"
def _inline_bar(percent: float, width: int, color: str) -> str:
percent = max(0.0, min(100.0, percent))
filled = int(round(width * percent / 100.0))
return f"[{color}]" + "█" * filled + "[/]" + "░" * (width - filled)
class DashboardTab(VerticalScroll):
"""Single-screen dashboard packing system / storage / network /
services / disks into one vertically-flowing view."""
DEFAULT_CSS = """
DashboardTab {
padding: 0 1;
}
"""
def compose(self) -> ComposeResult:
# System (CPU + MEM compact, inline summary + braille graph)
with Container(classes="panel"):
yield Static("SYSTEM", classes="panel-title")
self.cpu_line = Static("")
yield self.cpu_line
self.cpu_graph = BrailleGraph(color="red", y_max=100.0)
self.cpu_graph.styles.height = 4
yield self.cpu_graph
self.mem_line = Static("")
yield self.mem_line
with Container(classes="panel"):
yield Static("STORAGE", classes="panel-title")
self.storage_body = Static("")
yield self.storage_body
with Container(classes="panel"):
yield Static("NETWORK", classes="panel-title")
self.net_summary = Static("")
yield self.net_summary
with Horizontal(id="net-graphs"):
self.net_rx_graph = BrailleGraph(color="magenta")
self.net_rx_graph.styles.height = 4
self.net_tx_graph = BrailleGraph(color="blue")
self.net_tx_graph.styles.height = 4
yield self.net_rx_graph
yield self.net_tx_graph
with Container(classes="panel"):
yield Static("SERVICES", classes="panel-title")
self.services_body = Static("")
yield self.services_body
with Container(classes="panel"):
yield Static("DISKS", classes="panel-title")
self.disks_table = DataTable(zebra_stripes=False, show_cursor=False)
self.disks_table.add_columns("DISK", "MODEL", "SIZE", "TEMP", "HEALTH", "REALLOC", "PENDING")
yield self.disks_table
def update_system(self, s: SystemSnapshot) -> None:
bar = _inline_bar(s.cpu_percent, 30, theme.CPU_BAR)
temp = f" [grey70]temp[/] {s.temperature_c:.0f}°C" if s.temperature_c is not None else ""
self.cpu_line.update(
f"[bold]CPU[/] {s.cpu_percent:5.1f}% {bar} "
f"[grey70]load[/] {s.load_1:.2f}/{s.load_5:.2f}/{s.load_15:.2f} "
f"[grey70]{s.cpu_count}c[/] [grey70]up[/] {_fmt_uptime(s.uptime_seconds)}{temp}"
)
self.cpu_graph.push(s.cpu_percent)
mem_bar = _inline_bar(s.mem_percent, 30, theme.MEM_BAR)
used_mib = s.mem_used_kb / 1024
total_mib = s.mem_total_kb / 1024
cached_mib = s.mem_cached_kb / 1024
self.mem_line.update(
f"[bold]MEM[/] {s.mem_percent:5.1f}% {mem_bar} "
f"[grey70]used[/] {used_mib:6.0f} / {total_mib:6.0f} MiB "
f"[grey70]cached[/] {cached_mib:5.0f} MiB"
)
def update_storage(self, s: StorageSnapshot) -> None:
lines = []
for v in s.volumes:
color = (
theme.CRITICAL if v.percent >= 90
else theme.WARNING if v.percent >= 75
else theme.DISK_BAR
)
bar = _inline_bar(v.percent, 24, color)
lines.append(
f" {v.mount:<14} {v.used:>7} / {v.size:>7} {bar} {v.percent:5.1f}%"
)
if not lines:
lines.append(" (no volumes detected)")
if s.raids:
lines.append("")
for r in s.raids:
lines.append(f" [grey70]RAID[/] {r.name} {r.level:<8} {r.state}")
self.storage_body.update("\n".join(lines))
def update_network(self, s: NetworkSnapshot) -> None:
primary = s.primary()
if primary is None:
self.net_summary.update(" (no interface)")
return
self.net_rx_graph.push(primary.rx_bps)
self.net_tx_graph.push(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
def update_services(self, services: Iterable[ServiceInfo]) -> None:
items = list(services)
if not items:
self.services_body.update(" (no services detected)")
return
total = len(items)
running = sum(1 for s in items if s.running)
# The collector already sorted interesting+running first, so the
# head of the list is the most useful.
shown = items[: self.SERVICES_LIMIT]
max_name = min(20, max((len(s.name) for s in shown), default=8))
cells = []
for s in shown:
icon = f"[{theme.OK}]✅[/]" if s.running else f"[{theme.STOPPED}]⛔[/]"
name = (s.name[:max_name]).ljust(max_name)
cells.append(f"{icon} {name}")
per_row = 3
rows = [f" [dim]({running}/{total} running, showing top {len(shown)})[/]"]
for i in range(0, len(cells), per_row):
rows.append(" " + " ".join(cells[i:i + per_row]))
self.services_body.update("\n".join(rows))
def update_disks(self, snap: DisksSnapshot) -> None:
self.disks_table.clear()
for d in snap.disks:
temp = f"{d.temperature_c}°C" if d.temperature_c is not None else "—"
health_color = (
theme.OK if d.smart_ok is True
else theme.CRITICAL if d.smart_ok is False
else theme.WARNING
)
health = f"[{health_color}]{d.health}[/]"
model = (d.model or "—")[:30]
self.disks_table.add_row(
d.device.removeprefix("/dev/"), model, d.size or "—", temp, health,
str(d.reallocated) if d.reallocated is not None else "—",
str(d.pending) if d.pending is not None else "—",
)