v0.2.0: consolidated dashboard, 1s refresh tier, fixed log filter, DSM 7.3+ parsers

UI
- F1 Dashboard now holds CPU/MEM/storage/network/services/disks in a
  single dense view (was split across F1/F3/F4). F2 Logs stays separate.
- CPU and MEM rendered inline (label + bar + summary on one row) so the
  dashboard fits more on screen.
- DataTables and Services list capped for compactness; services sorted
  with interesting+running first.

Refresh
- Three tiers: fast (1s) for system+network, medium (5s) for storage+
  services, slow (30s) for disks (smartctl is heavy). Each tier runs
  in an exclusive worker group so overlaps are dropped, not queued.

Log filter
- Bindings (a / 1-4) moved from LogsTab container to the App, so they
  fire regardless of focus. Pressing one auto-switches to the logs tab.

Synology DSM 7.3+ compatibility (verified against DS720+ / DSM 7.3.2)
- DSM version read from /etc/VERSION (synoinfo.conf no longer has
  productversion on newer DSM).
- Disks discovered as /dev/sata{N} as well as /dev/sd{a..z} and NVMe;
  smartctl runs with -d sat so SMART data is actually readable.
- SAS-style smartctl output (Vendor:/Product:) parsed as a fallback
  when Device Model: is absent.
- Services collected via plain systemctl list-units, since
  synoservicectl/synosystemctl are gone on recent DSM builds.
- Network primary() picks the interface with the most accumulated
  traffic instead of hard-coded eth0.

Cleanup
- Removed ConfirmDialog and service start/stop/restart actions — the
  app is now truly read-only, matching the stated philosophy and
  eliminating a key conflict with `r`=refresh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jan Vetesnik 2026-06-13 17:57:32 +02:00 • committed by SW
parent 3fe7cff45e
commit b266bcb605
16 changed files with 322 additions and 430 deletions

View file

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

View file

@ -8,11 +8,15 @@ from dsmview.collectors.base import Collector
_SIZE_RE = re.compile(r"User Capacity:\s+[\d,]+\s+bytes\s+\[(?P<size>[^\]]+)\]") _SIZE_RE = re.compile(r"User Capacity:\s+[\d,]+\s+bytes\s+\[(?P<size>[^\]]+)\]")
_MODEL_RE = re.compile(r"Device Model:\s+(?P<model>.+)") _MODEL_RE = re.compile(r"Device Model:\s+(?P<model>.+)")
_TEMP_RE = re.compile(r"(?:Temperature_Celsius|Airflow_Temperature_Cel|Current Temperature)[^\d]+(\d+)") _VENDOR_RE = re.compile(r"Vendor:\s+(?P<vendor>\S+)")
_PRODUCT_RE = re.compile(r"Product:\s+(?P<product>.+)")
_TEMP_RE = re.compile(
r"(?:Temperature_Celsius|Airflow_Temperature_Cel)\s+\S+\s+\d+\s+\d+\s+\d+\s+\S+\s+\S+\s+\S+\s+(\d+)"
)
_HEALTH_RE = re.compile(r"SMART overall-health self-assessment test result:\s+(\S+)") _HEALTH_RE = re.compile(r"SMART overall-health self-assessment test result:\s+(\S+)")
_REALLOC_RE = re.compile(r"Reallocated_Sector_Ct\s+.*?\s(\d+)$", re.MULTILINE) _REALLOC_RE = re.compile(r"Reallocated_Sector_Ct\s+.*?\s(\d+)(?:\s|$)", re.MULTILINE)
_PENDING_RE = re.compile(r"Current_Pending_Sector\s+.*?\s(\d+)$", re.MULTILINE) _PENDING_RE = re.compile(r"Current_Pending_Sector\s+.*?\s(\d+)(?:\s|$)", re.MULTILINE)
_UNCORR_RE = re.compile(r"Offline_Uncorrectable\s+.*?\s(\d+)$", re.MULTILINE) _UNCORR_RE = re.compile(r"Offline_Uncorrectable\s+.*?\s(\d+)(?:\s|$)", re.MULTILINE)
@dataclass(slots=True) @dataclass(slots=True)
@ -37,20 +41,27 @@ class DisksCollector(Collector[DisksSnapshot]):
name = "disks" name = "disks"
async def collect(self) -> DisksSnapshot: async def collect(self) -> DisksSnapshot:
listing = await self.executor.run("ls /dev/sd[a-z] 2>/dev/null") # Synology exposes drives as /dev/sata{N} (newer models) or
# /dev/sd{a..z} (older). NVMe cache may be /dev/nvme{N}n1.
# List top-level devices only (no partitions like sata1p1).
listing = await self.executor.run(
"ls -d /dev/sata[0-9] /dev/sata[1-9][0-9] /dev/sd[a-z] /dev/nvme[0-9]n1 2>/dev/null"
)
devices: List[str] = [] devices: List[str] = []
for line in listing.stdout.split(): for line in listing.stdout.split():
line = line.strip() line = line.strip()
if line.startswith("/dev/sd") and len(line) == 8: if line.startswith("/dev/") and "p" not in line.rsplit("/", 1)[-1]:
devices.append(line) devices.append(line)
snap = DisksSnapshot() snap = DisksSnapshot()
if not devices: if not devices:
return snap return snap
smart_cmds = [f"smartctl -a {d} 2>/dev/null || true" for d in devices] # `-d sat` is needed on Synology because drives are presented via a
health_cmds = [f"smartctl -H {d} 2>/dev/null || true" for d in devices] # SCSI shim. Without it, smartctl reports "SMART unavailable".
smart_results = await self.executor.gather(smart_cmds, timeout=15.0) smart_cmds = [f"smartctl -a -d sat {d} 2>&1 || smartctl -a {d} 2>&1 || true" for d in devices]
health_cmds = [f"smartctl -H -d sat {d} 2>&1 || smartctl -H {d} 2>&1 || true" for d in devices]
smart_results = await self.executor.gather(smart_cmds, timeout=20.0)
health_results = await self.executor.gather(health_cmds, timeout=10.0) health_results = await self.executor.gather(health_cmds, timeout=10.0)
for device, smart, health in zip(devices, smart_results, health_results): for device, smart, health in zip(devices, smart_results, health_results):
@ -67,6 +78,12 @@ class DisksCollector(Collector[DisksSnapshot]):
m = _MODEL_RE.search(text) m = _MODEL_RE.search(text)
if m: if m:
info.model = m.group("model").strip() info.model = m.group("model").strip()
else:
# Enterprise drives often report as SCSI (Vendor:/Product:).
vendor = _VENDOR_RE.search(text)
product = _PRODUCT_RE.search(text)
if vendor and product:
info.model = f"{vendor.group('vendor').strip()} {product.group('product').strip()}"
m = _SIZE_RE.search(text) m = _SIZE_RE.search(text)
if m: if m:
info.size = m.group("size").strip() info.size = m.group("size").strip()

View file

@ -28,10 +28,17 @@ 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
# eth0-style names when no counters have accumulated 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: for s in self.samples:
if s.iface in ("eth0", "ovs_eth0", "bond0"): if s.iface in ("eth0", "eth1", "ovs_eth0", "bond0"):
return s return s
return self.samples[0] if self.samples else None return self.samples[0]
class NetworkCollector(Collector[NetworkSnapshot]): class NetworkCollector(Collector[NetworkSnapshot]):

View file

@ -1,19 +1,16 @@
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
_STATUS_RE = re.compile(r"\b(is\s+running|is\s+stopped|start/running|stop/waiting)\b", re.IGNORECASE)
@dataclass(slots=True) @dataclass(slots=True)
class ServiceInfo: class ServiceInfo:
name: str name: str
running: bool = False running: bool = False
status_text: str = "" description: str = ""
@dataclass(slots=True) @dataclass(slots=True)
@ -21,52 +18,58 @@ class ServicesSnapshot:
services: List[ServiceInfo] = field(default_factory=list) services: List[ServiceInfo] = field(default_factory=list)
# Names worth surfacing in the compact dashboard view.
INTERESTING = {
"smbd", "smb", "nmbd", "nginx", "sshd", "nfsd", "ftpd",
"avahi-daemon", "crond", "chronyd", "synoindexd", "findhostd",
"synocachefs", "synonetd", "synopkg", "synowebapi",
"pgsql", "synologdbd",
}
class ServicesCollector(Collector[ServicesSnapshot]): class ServicesCollector(Collector[ServicesSnapshot]):
"""Lists systemd services with running status.
Synology DSM 7.x uses systemd. Older `synoservicectl` / `synosystemctl`
are absent on recent DSM builds, so we go straight to `systemctl`.
"""
name = "services" name = "services"
DEFAULT_SERVICES = (
"smbd", "nmbd", "nginx", "sshd", "nfsd", "ftpd",
"avahi-daemon", "crond", "synoindexd",
"pkgctl-HyperBackup", "pkgctl-ActiveBackup", "pkgctl-CloudSync",
"pkgctl-MariaDB10", "pkgctl-ContainerManager",
)
async def collect(self) -> ServicesSnapshot: async def collect(self) -> ServicesSnapshot:
listing = await self.executor.run("synoservicectl --list 2>/dev/null || true") # --no-legend keeps the table machine-parseable; --plain removes
names = self._parse_list(listing.stdout) # the unicode tree characters that some systemd versions emit.
if not names: result = await self.executor.run(
names = list(self.DEFAULT_SERVICES) "systemctl list-units --type=service --all --no-legend --no-pager --plain 2>/dev/null || true",
timeout=10.0,
cmds = [f"synoservicectl --status {n} 2>&1 || true" for n in names] )
results = await self.executor.gather(cmds, timeout=15.0) services = self._parse(result.stdout)
snap = ServicesSnapshot() return ServicesSnapshot(services=services)
for name, res in zip(names, results):
info = ServiceInfo(name=name, status_text=res.stdout.strip())
running = self._is_running(res.stdout)
info.running = running
snap.services.append(info)
return snap
@staticmethod @staticmethod
def _parse_list(text: str) -> List[str]: def _parse(text: str) -> List[ServiceInfo]:
out: List[str] = [] out: List[ServiceInfo] = []
for line in text.splitlines(): for line in text.splitlines():
line = line.strip() parts = line.split(None, 4)
if not line or line.startswith("#"): if len(parts) < 4:
continue continue
for token in line.split(): unit = parts[0]
if token and token[0].isalpha() and "/" not in token: # systemctl marks failed/loaded/not-found in column 2; we want
out.append(token) # the SUB state in column 4 (running/dead/exited/etc).
seen: dict[str, None] = {} sub_state = parts[3]
for n in out: desc = parts[4] if len(parts) > 4 else ""
seen[n] = None if not unit.endswith(".service"):
return list(seen.keys()) continue
name = unit[: -len(".service")]
@staticmethod # Strip pkg- prefix DSM uses for package-installed services so
def _is_running(text: str) -> bool: # smbd shows as smbd, not pkg-smbd.
low = text.lower() display = name[4:] if name.startswith("pkg-") else name
if "is running" in low or "start/running" in low: running = sub_state == "running"
return True out.append(ServiceInfo(name=display, running=running, description=desc.strip().strip('"')))
if "is stopped" in low or "stop/waiting" in low: # Sort: interesting services first, then running ones, then the rest.
return False def sort_key(s: ServiceInfo) -> tuple:
return "running" in low and "not" not in low interesting = 0 if s.name in INTERESTING else 1
running = 0 if s.running else 1
return (interesting, running, s.name)
out.sort(key=sort_key)
return out

View file

@ -51,7 +51,9 @@ 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]
if not mount.startswith("/volume") and not mount.startswith("/"): # Only show user-facing data volumes. /, /tmp/* and friends
# are noise on Synology — they're the DSM system partition.
if not mount.startswith("/volume"):
continue continue
if device in ("tmpfs", "devtmpfs", "overlay", "none"): if device in ("tmpfs", "devtmpfs", "overlay", "none"):
continue continue

View file

@ -67,7 +67,8 @@ class SystemCollector(Collector[SystemSnapshot]):
"cat /proc/cpuinfo", "cat /proc/cpuinfo",
"cat /proc/uptime", "cat /proc/uptime",
"cat /proc/sys/kernel/hostname", "cat /proc/sys/kernel/hostname",
"cat /etc/synoinfo.conf 2>/dev/null | grep -E '^(unique|productversion|buildnumber|upnpmodelname)='", "cat /etc/VERSION 2>/dev/null",
"cat /etc/synoinfo.conf 2>/dev/null | grep -E '^(unique|upnpmodelname)='",
"synotemperature 2>/dev/null || true", "synotemperature 2>/dev/null || true",
] ]
results = await self.executor.gather(cmds) results = await self.executor.gather(cmds)
@ -78,8 +79,9 @@ class SystemCollector(Collector[SystemSnapshot]):
self._parse_cpuinfo(results[3].stdout, snap) self._parse_cpuinfo(results[3].stdout, snap)
self._parse_uptime(results[4].stdout, snap) self._parse_uptime(results[4].stdout, snap)
snap.hostname = results[5].stdout.strip() snap.hostname = results[5].stdout.strip()
self._parse_synoinfo(results[6].stdout, snap) self._parse_version(results[6].stdout, snap)
self._parse_temp(results[7].stdout, snap) self._parse_synoinfo(results[7].stdout, snap)
self._parse_temp(results[8].stdout, snap)
return snap return snap
@staticmethod @staticmethod
@ -154,6 +156,24 @@ class SystemCollector(Collector[SystemSnapshot]):
except ValueError: except ValueError:
pass pass
@staticmethod
def _parse_version(text: str, snap: SystemSnapshot) -> None:
# /etc/VERSION is the authoritative source for productversion +
# buildnumber on DSM 7.x. synoinfo.conf may or may not have it.
product = ""
build = ""
for line in text.splitlines():
key, _, value = line.partition("=")
value = value.strip().strip('"')
if key == "productversion":
product = value
elif key == "buildnumber":
build = value
if product and build:
snap.dsm_version = f"{product}-{build}"
elif product:
snap.dsm_version = product
@staticmethod @staticmethod
def _parse_synoinfo(text: str, snap: SystemSnapshot) -> None: def _parse_synoinfo(text: str, snap: SystemSnapshot) -> None:
for line in text.splitlines(): for line in text.splitlines():
@ -161,10 +181,6 @@ class SystemCollector(Collector[SystemSnapshot]):
value = value.strip().strip('"') value = value.strip().strip('"')
if key == "upnpmodelname": if key == "upnpmodelname":
snap.dsm_model = value snap.dsm_model = value
elif key == "productversion":
snap.dsm_version = value
elif key == "buildnumber" and snap.dsm_version:
snap.dsm_version = f"{snap.dsm_version}-{value}"
@staticmethod @staticmethod
def _parse_temp(text: str, snap: SystemSnapshot) -> None: def _parse_temp(text: str, snap: SystemSnapshot) -> None:

View file

@ -1,15 +1,10 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import time
from datetime import datetime from datetime import datetime
from typing import Optional
from textual import on, work
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.binding import Binding from textual.binding import Binding
from textual.containers import Container
from textual.screen import ModalScreen
from textual.widgets import Footer, Static, TabbedContent, TabPane from textual.widgets import Footer, Static, TabbedContent, TabPane
from dsmview.collectors import ( from dsmview.collectors import (
@ -23,37 +18,14 @@ from dsmview.collectors import (
from dsmview.ssh.connection import NasConnection from dsmview.ssh.connection import NasConnection
from dsmview.ssh.executor import Executor from dsmview.ssh.executor import Executor
from dsmview.ui import theme from dsmview.ui import theme
from dsmview.ui.tabs import DashboardTab, DisksTab, LogsTab, ServicesTab from dsmview.ui.tabs import DashboardTab, LogsTab
REFRESH_INTERVAL = 10.0 # Three refresh tiers — fast for live metrics, medium for storage/services,
# slow for SMART (smartctl is heavy and runs per disk).
REFRESH_FAST = 1.0
class ConfirmDialog(ModalScreen[bool]): REFRESH_MED = 5.0
"""Y/N confirmation modal for destructive actions.""" REFRESH_SLOW = 30.0
BINDINGS = [
Binding("y", "confirm(True)", "Yes", show=False),
Binding("n", "confirm(False)", "No", show=False),
Binding("escape", "confirm(False)", "Cancel", show=False),
]
def __init__(self, action: str, service: str, host: str) -> None:
super().__init__()
self.action = action
self.service = service
self.host = host
def compose(self) -> ComposeResult:
with Container(id="dialog"):
yield Static(f"⚠️ CONFIRM", id="dialog-title")
yield Static(f"{self.action.title()} service: [bold]{self.service}[/]")
yield Static(f"on: {self.host}")
yield Static("")
yield Static("[Y] Potvrdit [N] Zrušit")
def action_confirm(self, ok: bool) -> None:
self.dismiss(ok)
class DsmviewApp(App): class DsmviewApp(App):
@ -65,14 +37,17 @@ class DsmviewApp(App):
Binding("r", "refresh_now", "Refresh"), Binding("r", "refresh_now", "Refresh"),
Binding("f1", "switch_tab('dashboard')", "Dash"), Binding("f1", "switch_tab('dashboard')", "Dash"),
Binding("f2", "switch_tab('logs')", "Logs"), Binding("f2", "switch_tab('logs')", "Logs"),
Binding("f3", "switch_tab('services')", "Services"), Binding("a", "filter('ALL')", "All", show=False),
Binding("f4", "switch_tab('disks')", "Disks"), Binding("1", "filter('ERROR')", "Err", show=False),
Binding("2", "filter('WARN')", "Warn", show=False),
Binding("3", "filter('SECURITY')", "Sec", show=False),
Binding("4", "filter('INFO')", "Info", show=False),
] ]
def __init__(self, connection: NasConnection) -> None: def __init__(self, connection: NasConnection) -> None:
super().__init__() super().__init__()
self.connection = connection self.connection = connection
self.executor = Executor(connection, max_workers=6) self.executor = Executor(connection, max_workers=8)
self.system = SystemCollector(self.executor) self.system = SystemCollector(self.executor)
self.storage = StorageCollector(self.executor) self.storage = StorageCollector(self.executor)
self.disks = DisksCollector(self.executor) self.disks = DisksCollector(self.executor)
@ -80,7 +55,6 @@ class DsmviewApp(App):
self.logs = LogCollector(self.executor) self.logs = LogCollector(self.executor)
self.services = ServicesCollector(self.executor) self.services = ServicesCollector(self.executor)
self._dsm_label: str = "—" self._dsm_label: str = "—"
self._refresh_lock = asyncio.Lock()
self._tail_workers: list = [] self._tail_workers: list = []
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
@ -93,17 +67,13 @@ class DsmviewApp(App):
with TabPane("F2 Logs", id="logs"): with TabPane("F2 Logs", id="logs"):
self.logs_tab = LogsTab() self.logs_tab = LogsTab()
yield self.logs_tab yield self.logs_tab
with TabPane("F3 Services", id="services"):
self.services_tab = ServicesTab(on_action=self._handle_service_action)
yield self.services_tab
with TabPane("F4 Disks", id="disks"):
self.disks_tab = DisksTab()
yield self.disks_tab
yield Footer() yield Footer()
def on_mount(self) -> None: def on_mount(self) -> None:
self._update_topbar() self._update_topbar()
self.set_interval(REFRESH_INTERVAL, self.action_refresh_now) self.set_interval(REFRESH_FAST, self._tick_fast)
self.set_interval(REFRESH_MED, self._tick_medium)
self.set_interval(REFRESH_SLOW, self._tick_slow)
self.set_interval(1.0, self._update_topbar) self.set_interval(1.0, self._update_topbar)
self.action_refresh_now() self.action_refresh_now()
self._start_log_tails() self._start_log_tails()
@ -118,44 +88,63 @@ class DsmviewApp(App):
self.connection.close() self.connection.close()
def action_switch_tab(self, tab_id: str) -> None: def action_switch_tab(self, tab_id: str) -> None:
tabbed = self.query_one(TabbedContent) self.query_one(TabbedContent).active = tab_id
tabbed.active = tab_id
def action_filter(self, name: str) -> None:
self.query_one(TabbedContent).active = "logs"
self.logs_tab.set_filter(name)
def action_refresh_now(self) -> None: def action_refresh_now(self) -> None:
self.run_worker(self._refresh_all(), exclusive=True, group="refresh") self._tick_fast()
self._tick_medium()
self._tick_slow()
async def _refresh_all(self) -> None: def _tick_fast(self) -> None:
async with self._refresh_lock: self.run_worker(self._refresh_fast(), exclusive=True, group="refresh-fast")
def _tick_medium(self) -> None:
self.run_worker(self._refresh_medium(), exclusive=True, group="refresh-med")
def _tick_slow(self) -> None:
self.run_worker(self._refresh_slow(), exclusive=True, group="refresh-slow")
async def _refresh_fast(self) -> None:
try: try:
system_task = asyncio.create_task(self.system.collect()) system_snap, net_snap = await asyncio.gather(
storage_task = asyncio.create_task(self.storage.collect()) self.system.collect(),
network_task = asyncio.create_task(self.network.collect()) self.network.collect(),
services_task = asyncio.create_task(self.services.collect()) )
disks_task = asyncio.create_task(self.disks.collect())
system_snap = await system_task
storage_snap = await storage_task
net_snap = await network_task
services_snap = await services_task
disks_snap = await disks_task
except Exception as e: except Exception as e:
self.notify(f"Refresh error: {e}", severity="error", timeout=5) self.notify(f"fast refresh error: {e}", severity="error", timeout=4)
return return
if not self._dsm_label or self._dsm_label == "—":
self._dsm_label = self._make_dsm_label(system_snap)
self._update_topbar()
self.dashboard.update_system(system_snap) self.dashboard.update_system(system_snap)
self.dashboard.update_storage(storage_snap)
self.dashboard.update_network(net_snap) self.dashboard.update_network(net_snap)
self.services_tab.update_services(services_snap.services) if self._dsm_label == "—":
self.disks_tab.update_disks(disks_snap) self._dsm_label = self._make_dsm_label(system_snap)
self.disks_tab.update_raid(storage_snap)
async def _refresh_medium(self) -> None:
try:
storage_snap, services_snap = await asyncio.gather(
self.storage.collect(),
self.services.collect(),
)
except Exception as e:
self.notify(f"storage/services error: {e}", severity="warning", timeout=4)
return
self.dashboard.update_storage(storage_snap)
self.dashboard.update_services(services_snap.services)
async def _refresh_slow(self) -> None:
try:
disks_snap = await self.disks.collect()
except Exception as e:
self.notify(f"disks error: {e}", severity="warning", timeout=4)
return
self.dashboard.update_disks(disks_snap)
def _start_log_tails(self) -> None: def _start_log_tails(self) -> None:
self.run_worker(self._tail_logs_initial(), group="logs-init", exclusive=True) self.run_worker(self._tail_logs_initial(), group="logs-init", exclusive=True)
for i, (path, source) in enumerate(self.logs.SOURCES): for i in range(len(self.logs.SOURCES)):
w = self.run_worker(self._tail_logs(i), group=f"tail-{i}", exclusive=False) w = self.run_worker(self._tail_logs(i), group=f"tail-{i}", exclusive=False)
self._tail_workers.append(w) self._tail_workers.append(w)
@ -164,7 +153,7 @@ class DsmviewApp(App):
initial = await self.logs.collect() initial = await self.logs.collect()
self.logs_tab.push_initial(initial) self.logs_tab.push_initial(initial)
except Exception as e: except Exception as e:
self.notify(f"Log fetch error: {e}", severity="warning", timeout=4) self.notify(f"log fetch error: {e}", severity="warning", timeout=4)
async def _tail_logs(self, source_index: int) -> None: async def _tail_logs(self, source_index: int) -> None:
try: try:
@ -173,25 +162,6 @@ class DsmviewApp(App):
except Exception: except Exception:
return return
def _handle_service_action(self, action: str, name: str) -> None:
def on_result(ok: bool | None) -> None:
if ok:
self.run_worker(self._run_service_action(action, name), exclusive=False, group="svc")
self.push_screen(
ConfirmDialog(action, name, self.connection.target.label),
on_result,
)
async def _run_service_action(self, action: str, name: str) -> None:
flag = {"restart": "--restart", "stop": "--stop", "start": "--start"}[action]
result = await self.executor.run(f"synoservicectl {flag} {name}")
if result.ok:
self.notify(f"{action} {name}: OK")
else:
self.notify(f"{action} {name} failed: {result.stderr or result.stdout}", severity="error")
await self._refresh_all()
def _update_topbar(self) -> None: def _update_topbar(self) -> None:
now = datetime.now().strftime("%H:%M:%S") now = datetime.now().strftime("%H:%M:%S")
target = self.connection.target.label target = self.connection.target.label

View file

@ -1,6 +1,4 @@
from dsmview.ui.tabs.dashboard import DashboardTab from dsmview.ui.tabs.dashboard import DashboardTab
from dsmview.ui.tabs.logs import LogsTab from dsmview.ui.tabs.logs import LogsTab
from dsmview.ui.tabs.services import ServicesTab
from dsmview.ui.tabs.disks import DisksTab
__all__ = ["DashboardTab", "LogsTab", "ServicesTab", "DisksTab"] __all__ = ["DashboardTab", "LogsTab"]

View file

@ -1,14 +1,18 @@
from __future__ import annotations from __future__ import annotations
from textual.app import ComposeResult from typing import Iterable
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Static
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.network import NetworkSnapshot
from dsmview.collectors.services import ServiceInfo
from dsmview.collectors.storage import StorageSnapshot from dsmview.collectors.storage import StorageSnapshot
from dsmview.collectors.system import SystemSnapshot from dsmview.collectors.system import SystemSnapshot
from dsmview.ui import theme from dsmview.ui import theme
from dsmview.ui.widgets import BrailleGraph, MeterBar from dsmview.ui.widgets import BrailleGraph
def _fmt_bytes_per_sec(b: float) -> str: def _fmt_bytes_per_sec(b: float) -> str:
@ -30,76 +34,100 @@ def _fmt_uptime(s: float) -> str:
return f"{hours:02d}h {minutes:02d}m" return f"{hours:02d}h {minutes:02d}m"
class DashboardTab(Container): def _inline_bar(percent: float, width: int, color: str) -> str:
def compose(self) -> ComposeResult: percent = max(0.0, min(100.0, percent))
with Container(id="dashboard-grid"): filled = int(round(width * percent / 100.0))
with Vertical(id="cpu-panel", classes="panel"): return f"[{color}]" + "█" * filled + "[/]" + "░" * (width - filled)
yield Static("CPU", classes="panel-title")
self.cpu_meter = MeterBar("CPU", color=theme.CPU_BAR)
self.cpu_meter.add_class("meter-cpu")
yield self.cpu_meter
self.cpu_graph = BrailleGraph(color="red", y_max=100.0)
yield self.cpu_graph
self.cpu_summary = Static("", id="cpu-summary")
yield self.cpu_summary
with Vertical(id="storage-panel", classes="panel"):
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") yield Static("STORAGE", classes="panel-title")
self.storage_body = Static("") self.storage_body = Static("")
yield self.storage_body yield self.storage_body
with Vertical(id="mem-panel", classes="panel"): with Container(classes="panel"):
yield Static("MEMORY", classes="panel-title")
self.mem_meter = MeterBar("MEM", color=theme.MEM_BAR)
self.mem_meter.add_class("meter-mem")
yield self.mem_meter
self.mem_summary = Static("", id="mem-summary")
yield self.mem_summary
with Vertical(id="net-panel", classes="panel"):
yield Static("NETWORK", classes="panel-title") yield Static("NETWORK", classes="panel-title")
self.net_summary = Static("") self.net_summary = Static("")
yield self.net_summary yield self.net_summary
with Horizontal(): with Horizontal(id="net-graphs"):
self.net_rx_graph = BrailleGraph(color="magenta") 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 = BrailleGraph(color="blue")
self.net_tx_graph.styles.height = 4
yield self.net_rx_graph yield self.net_rx_graph
yield self.net_tx_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: def update_system(self, s: SystemSnapshot) -> None:
self.cpu_meter.update(s.cpu_percent) bar = _inline_bar(s.cpu_percent, 30, theme.CPU_BAR)
self.cpu_graph.push(s.cpu_percent) temp = f" [grey70]temp[/] {s.temperature_c:.0f}°C" if s.temperature_c is not None else ""
temp = f" temp {s.temperature_c:.0f}°C" if s.temperature_c is not None else "" self.cpu_line.update(
self.cpu_summary.update( f"[bold]CPU[/] {s.cpu_percent:5.1f}% {bar} "
f"load {s.load_1:.2f} {s.load_5:.2f} {s.load_15:.2f} " f"[grey70]load[/] {s.load_1:.2f}/{s.load_5:.2f}/{s.load_15:.2f} "
f"cores {s.cpu_count} up {_fmt_uptime(s.uptime_seconds)}{temp}" f"[grey70]{s.cpu_count}c[/] [grey70]up[/] {_fmt_uptime(s.uptime_seconds)}{temp}"
) )
self.mem_meter.update(s.mem_percent) self.cpu_graph.push(s.cpu_percent)
used_mb = s.mem_used_kb / 1024
total_mb = s.mem_total_kb / 1024 mem_bar = _inline_bar(s.mem_percent, 30, theme.MEM_BAR)
cached_mb = s.mem_cached_kb / 1024 used_mib = s.mem_used_kb / 1024
self.mem_summary.update( total_mib = s.mem_total_kb / 1024
f"used {used_mb:7.0f} MiB / total {total_mb:7.0f} MiB cached {cached_mb:6.0f} MiB" 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: def update_storage(self, s: StorageSnapshot) -> None:
lines = [] lines = []
for v in s.volumes: for v in s.volumes:
bar_w = 20
filled = int(round(bar_w * v.percent / 100.0))
color = ( color = (
theme.CRITICAL if v.percent >= 90 theme.CRITICAL if v.percent >= 90
else theme.WARNING if v.percent >= 75 else theme.WARNING if v.percent >= 75
else theme.DISK_BAR else theme.DISK_BAR
) )
bar = f"[{color}]" + "█" * filled + "[/]" + "░" * (bar_w - filled) bar = _inline_bar(v.percent, 24, color)
lines.append( lines.append(
f"{v.mount:<14} {v.used:>6}/{v.size:>6} {bar} {v.percent:5.1f}%" f" {v.mount:<14} {v.used:>7} / {v.size:>7} {bar} {v.percent:5.1f}%"
) )
if not lines: if not lines:
lines.append(" (no volumes detected)") lines.append(" (no volumes detected)")
if s.raids:
lines.append("")
for r in s.raids: for r in s.raids:
lines.append(f" RAID {r.name} {r.level} {r.state}") lines.append(f" [grey70]RAID[/] {r.name} {r.level:<8} {r.state}")
self.storage_body.update("\n".join(lines)) self.storage_body.update("\n".join(lines))
def update_network(self, s: NetworkSnapshot) -> None: def update_network(self, s: NetworkSnapshot) -> None:
@ -110,7 +138,48 @@ class DashboardTab(Container):
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( self.net_summary.update(
f"{primary.iface} " f" [bold]{primary.iface}[/] "
f"[{theme.NET_DOWN}]↓ {_fmt_bytes_per_sec(primary.rx_bps)}[/] " f"[{theme.NET_DOWN}]↓ {_fmt_bytes_per_sec(primary.rx_bps)}[/] "
f"[{theme.NET_UP}]↑ {_fmt_bytes_per_sec(primary.tx_bps)}[/]" f"[{theme.NET_UP}]↑ {_fmt_bytes_per_sec(primary.tx_bps)}[/]"
) )
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 "—",
)

View file

@ -1,49 +0,0 @@
from __future__ import annotations
from textual.app import ComposeResult
from textual.containers import Container
from textual.widgets import DataTable, Static
from dsmview.collectors.disks import DisksSnapshot
from dsmview.collectors.storage import StorageSnapshot
from dsmview.ui import theme
class DisksTab(Container):
def compose(self) -> ComposeResult:
with Container(classes="panel"):
yield Static("DISKS", classes="panel-title")
self.disks_table = DataTable(zebra_stripes=False)
self.disks_table.add_columns(
"DISK", "MODEL", "SIZE", "TEMP", "HEALTH", "REALLOC", "PENDING", "UNCORR",
)
yield self.disks_table
yield Static("RAID", classes="panel-title")
self.raid_view = Static("")
yield self.raid_view
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}[/]"
self.disks_table.add_row(
d.device, d.model or "—", 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 "—",
str(d.uncorrectable) if d.uncorrectable is not None else "—",
)
def update_raid(self, snap: StorageSnapshot) -> None:
if not snap.raids:
self.raid_view.update("(no RAID arrays detected)")
return
lines = []
for r in snap.raids:
lines.append(f"{r.name:<10} {r.level:<8} {r.state} devices: {', '.join(r.devices) or '—'}")
self.raid_view.update("\n".join(lines))

View file

@ -4,8 +4,7 @@ from collections import deque
from typing import Deque from typing import Deque
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.binding import Binding from textual.containers import Container
from textual.containers import Container, Horizontal
from textual.widgets import RichLog, Static from textual.widgets import RichLog, Static
from dsmview.collectors.logs import LogLine, Severity from dsmview.collectors.logs import LogLine, Severity
@ -16,13 +15,9 @@ _MAX_LINES = 2000
class LogsTab(Container): class LogsTab(Container):
BINDINGS = [ """Live log tail with severity filter. The filter keys (a / 1-4) are
Binding("a", "filter('ALL')", "ALL", show=False), bound at the App level — pressing them anywhere switches to this tab
Binding("1", "filter('ERROR')", "ERROR", show=False), and applies the filter."""
Binding("2", "filter('WARN')", "WARN", show=False),
Binding("3", "filter('SECURITY')", "SECURITY", show=False),
Binding("4", "filter('INFO')", "INFO", show=False),
]
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
@ -31,13 +26,19 @@ class LogsTab(Container):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with Container(classes="panel"): with Container(classes="panel"):
yield Static("LOGS — [A]ll [1]Err [2]Warn [3]Sec [4]Info", classes="panel-title", id="logs-header") yield Static(
"LOGS — [A]LL [1]ERROR [2]WARN [3]SECURITY [4]INFO",
classes="panel-title",
id="logs-header",
)
self.status_bar = Static("filter: ALL", id="logs-status") self.status_bar = Static("filter: ALL", id="logs-status")
yield self.status_bar yield self.status_bar
self.log_view = RichLog(highlight=False, markup=True, wrap=False, max_lines=_MAX_LINES, id="logs-view") self.log_view = RichLog(
highlight=False, markup=True, wrap=False, max_lines=_MAX_LINES, id="logs-view"
)
yield self.log_view yield self.log_view
def action_filter(self, name: str) -> None: def set_filter(self, name: str) -> None:
self._filter = name self._filter = name
self.status_bar.update(f"filter: {name}") self.status_bar.update(f"filter: {name}")
self._rerender() self._rerender()

View file

@ -1,45 +0,0 @@
from __future__ import annotations
from typing import Callable, Iterable
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Container
from textual.widgets import Static
from dsmview.collectors.services import ServiceInfo
from dsmview.ui.widgets import ServiceList
class ServicesTab(Container):
BINDINGS = [
Binding("r", "service_action('restart')", "Restart", show=False),
Binding("s", "service_action('stop')", "Stop", show=False),
Binding("S", "service_action('start')", "Start", show=False),
]
def __init__(self, on_action: Callable[[str, str], None]) -> None:
super().__init__()
self._on_action = on_action
def compose(self) -> ComposeResult:
with Container(classes="panel"):
yield Static(
"SERVICES — [enter] detail [r] restart [s] stop [S] start",
classes="panel-title",
)
self.table = ServiceList()
yield self.table
def update_services(self, services: Iterable[ServiceInfo]) -> None:
self.table.update_services(services)
def action_service_action(self, action: str) -> None:
row = self.table.cursor_row
if row is None or row < 0 or row >= self.table.row_count:
return
row_key = self.table.coordinate_to_cell_key((row, 0)).row_key
name = row_key.value if row_key else None
if not name:
return
self._on_action(action, name)

View file

@ -27,11 +27,6 @@ Screen {{
color: {BORDER_TITLE}; color: {BORDER_TITLE};
}} }}
Header {{
background: {BG};
color: {BORDER_TITLE};
}}
Footer {{ Footer {{
background: {BG}; background: {BG};
color: {BORDER_TITLE}; color: {BORDER_TITLE};
@ -68,52 +63,24 @@ Tab.-active {{
background: {BG}; background: {BG};
color: {BORDER_TITLE}; color: {BORDER_TITLE};
padding: 0 1; padding: 0 1;
margin-bottom: 1;
height: auto;
}} }}
.panel > .panel-title {{ .panel > Static.panel-title {{
color: {BORDER_TITLE}; color: {BORDER_TITLE};
text-style: bold; text-style: bold;
height: 1;
}} }}
.meter-cpu Bar > .bar--bar {{ #net-graphs {{
color: {CPU_BAR}; height: 5;
}}
.meter-mem Bar > .bar--bar {{
color: {MEM_BAR};
}}
.meter-disk Bar > .bar--bar {{
color: {DISK_BAR};
}}
.severity-error {{
color: {LOG_ERROR};
}}
.severity-warn {{
color: {LOG_WARN};
}}
.severity-info {{
color: {LOG_INFO};
}}
.severity-security {{
color: {LOG_SECURITY};
}}
.svc-running {{
color: {OK};
}}
.svc-stopped {{
color: {STOPPED};
}} }}
DataTable {{ DataTable {{
background: {BG}; background: {BG};
color: {BORDER_TITLE}; color: {BORDER_TITLE};
height: auto;
}} }}
DataTable > .datatable--header {{ DataTable > .datatable--header {{
@ -121,43 +88,4 @@ DataTable > .datatable--header {{
color: {BORDER_TITLE}; color: {BORDER_TITLE};
text-style: bold; text-style: bold;
}} }}
DataTable > .datatable--cursor {{
background: {BORDER};
color: {BG};
}}
#dashboard-grid {{
layout: grid;
grid-size: 2 3;
grid-columns: 1fr 1fr;
grid-rows: 1fr 1fr auto;
grid-gutter: 0;
}}
#cpu-panel, #mem-panel, #storage-panel, #net-panel, #services-row, #logs-row {{
height: 100%;
width: 100%;
}}
#services-row, #logs-row {{
column-span: 2;
}}
ConfirmDialog {{
align: center middle;
}}
ConfirmDialog > #dialog {{
width: 60;
height: 9;
background: {BG};
border: round {WARNING};
padding: 1 2;
}}
ConfirmDialog #dialog-title {{
color: {WARNING};
text-style: bold;
}}
""" """

View file

@ -1,5 +1,4 @@
from dsmview.ui.widgets.braille_graph import BrailleGraph from dsmview.ui.widgets.braille_graph import BrailleGraph
from dsmview.ui.widgets.meter_bar import MeterBar from dsmview.ui.widgets.meter_bar import MeterBar
from dsmview.ui.widgets.service_list import ServiceList
__all__ = ["BrailleGraph", "MeterBar", "ServiceList"] __all__ = ["BrailleGraph", "MeterBar"]

View file

@ -1,24 +0,0 @@
from __future__ import annotations
from typing import Iterable
from textual.widgets import DataTable
from dsmview.collectors.services import ServiceInfo
from dsmview.ui import theme
class ServiceList(DataTable):
"""Tabular service overview backed by Textual's DataTable."""
def on_mount(self) -> None:
self.cursor_type = "row"
self.zebra_stripes = False
self.add_columns("SERVICE", "STATUS", "INFO")
def update_services(self, services: Iterable[ServiceInfo]) -> None:
self.clear()
for svc in services:
status_text = "[green]✅ running[/]" if svc.running else "[grey50]⛔ stopped[/]"
info = svc.status_text.splitlines()[0] if svc.status_text else ""
self.add_row(svc.name, status_text, info, key=svc.name)

View file

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "dsmview" name = "dsmview"
version = "0.1.0" version = "0.2.0"
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" }