From b1e0ad0585a5f4b6a66029d283143b2397015b61 Mon Sep 17 00:00:00 2001 From: Jan Vetesnik Date: Sat, 13 Jun 2026 17:57:32 +0200 Subject: [PATCH] v0.2.0: consolidated dashboard, 1s refresh tier, fixed log filter, DSM 7.3+ parsers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- dsmview/__init__.py | 2 +- dsmview/collectors/disks.py | 35 ++++-- dsmview/collectors/network.py | 11 +- dsmview/collectors/services.py | 93 ++++++++------- dsmview/collectors/storage.py | 4 +- dsmview/collectors/system.py | 30 +++-- dsmview/ui/app.py | 158 ++++++++++--------------- dsmview/ui/tabs/__init__.py | 4 +- dsmview/ui/tabs/dashboard.py | 181 ++++++++++++++++++++--------- dsmview/ui/tabs/disks.py | 49 -------- dsmview/ui/tabs/logs.py | 25 ++-- dsmview/ui/tabs/services.py | 45 ------- dsmview/ui/theme.py | 86 ++------------ dsmview/ui/widgets/__init__.py | 3 +- dsmview/ui/widgets/service_list.py | 24 ---- pyproject.toml | 2 +- 16 files changed, 322 insertions(+), 430 deletions(-) delete mode 100644 dsmview/ui/tabs/disks.py delete mode 100644 dsmview/ui/tabs/services.py delete mode 100644 dsmview/ui/widgets/service_list.py diff --git a/dsmview/__init__.py b/dsmview/__init__.py index 3dc1f76..d3ec452 100644 --- a/dsmview/__init__.py +++ b/dsmview/__init__.py @@ -1 +1 @@ -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/dsmview/collectors/disks.py b/dsmview/collectors/disks.py index dacf148..2868fe7 100644 --- a/dsmview/collectors/disks.py +++ b/dsmview/collectors/disks.py @@ -8,11 +8,15 @@ from dsmview.collectors.base import Collector _SIZE_RE = re.compile(r"User Capacity:\s+[\d,]+\s+bytes\s+\[(?P[^\]]+)\]") _MODEL_RE = re.compile(r"Device Model:\s+(?P.+)") -_TEMP_RE = re.compile(r"(?:Temperature_Celsius|Airflow_Temperature_Cel|Current Temperature)[^\d]+(\d+)") +_VENDOR_RE = re.compile(r"Vendor:\s+(?P\S+)") +_PRODUCT_RE = re.compile(r"Product:\s+(?P.+)") +_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+)") -_REALLOC_RE = re.compile(r"Reallocated_Sector_Ct\s+.*?\s(\d+)$", re.MULTILINE) -_PENDING_RE = re.compile(r"Current_Pending_Sector\s+.*?\s(\d+)$", re.MULTILINE) -_UNCORR_RE = re.compile(r"Offline_Uncorrectable\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+)(?:\s|$)", re.MULTILINE) +_UNCORR_RE = re.compile(r"Offline_Uncorrectable\s+.*?\s(\d+)(?:\s|$)", re.MULTILINE) @dataclass(slots=True) @@ -37,20 +41,27 @@ class DisksCollector(Collector[DisksSnapshot]): name = "disks" 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] = [] for line in listing.stdout.split(): 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) snap = DisksSnapshot() if not devices: return snap - smart_cmds = [f"smartctl -a {d} 2>/dev/null || true" for d in devices] - health_cmds = [f"smartctl -H {d} 2>/dev/null || true" for d in devices] - smart_results = await self.executor.gather(smart_cmds, timeout=15.0) + # `-d sat` is needed on Synology because drives are presented via a + # SCSI shim. Without it, smartctl reports "SMART unavailable". + 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) for device, smart, health in zip(devices, smart_results, health_results): @@ -67,6 +78,12 @@ class DisksCollector(Collector[DisksSnapshot]): m = _MODEL_RE.search(text) if m: 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) if m: info.size = m.group("size").strip() diff --git a/dsmview/collectors/network.py b/dsmview/collectors/network.py index a4db17e..bdff4bb 100644 --- a/dsmview/collectors/network.py +++ b/dsmview/collectors/network.py @@ -28,10 +28,17 @@ 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. + 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", "ovs_eth0", "bond0"): + if s.iface in ("eth0", "eth1", "ovs_eth0", "bond0"): return s - return self.samples[0] if self.samples else None + return self.samples[0] class NetworkCollector(Collector[NetworkSnapshot]): diff --git a/dsmview/collectors/services.py b/dsmview/collectors/services.py index ab23b4d..ea2930e 100644 --- a/dsmview/collectors/services.py +++ b/dsmview/collectors/services.py @@ -1,19 +1,16 @@ from __future__ import annotations -import re from dataclasses import dataclass, field from typing import List 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) class ServiceInfo: name: str running: bool = False - status_text: str = "" + description: str = "" @dataclass(slots=True) @@ -21,52 +18,58 @@ class ServicesSnapshot: 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]): + """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" - 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: - listing = await self.executor.run("synoservicectl --list 2>/dev/null || true") - names = self._parse_list(listing.stdout) - if not names: - names = list(self.DEFAULT_SERVICES) - - cmds = [f"synoservicectl --status {n} 2>&1 || true" for n in names] - results = await self.executor.gather(cmds, timeout=15.0) - snap = ServicesSnapshot() - 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 + # --no-legend keeps the table machine-parseable; --plain removes + # the unicode tree characters that some systemd versions emit. + result = await self.executor.run( + "systemctl list-units --type=service --all --no-legend --no-pager --plain 2>/dev/null || true", + timeout=10.0, + ) + services = self._parse(result.stdout) + return ServicesSnapshot(services=services) @staticmethod - def _parse_list(text: str) -> List[str]: - out: List[str] = [] + def _parse(text: str) -> List[ServiceInfo]: + out: List[ServiceInfo] = [] for line in text.splitlines(): - line = line.strip() - if not line or line.startswith("#"): + parts = line.split(None, 4) + if len(parts) < 4: continue - for token in line.split(): - if token and token[0].isalpha() and "/" not in token: - out.append(token) - seen: dict[str, None] = {} - for n in out: - seen[n] = None - return list(seen.keys()) - - @staticmethod - def _is_running(text: str) -> bool: - low = text.lower() - if "is running" in low or "start/running" in low: - return True - if "is stopped" in low or "stop/waiting" in low: - return False - return "running" in low and "not" not in low + unit = parts[0] + # systemctl marks failed/loaded/not-found in column 2; we want + # the SUB state in column 4 (running/dead/exited/etc). + sub_state = parts[3] + desc = parts[4] if len(parts) > 4 else "" + if not unit.endswith(".service"): + continue + name = unit[: -len(".service")] + # Strip pkg- prefix DSM uses for package-installed services so + # smbd shows as smbd, not pkg-smbd. + display = name[4:] if name.startswith("pkg-") else name + running = sub_state == "running" + out.append(ServiceInfo(name=display, running=running, description=desc.strip().strip('"'))) + # Sort: interesting services first, then running ones, then the rest. + def sort_key(s: ServiceInfo) -> tuple: + 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 diff --git a/dsmview/collectors/storage.py b/dsmview/collectors/storage.py index 4c5014d..d575f6e 100644 --- a/dsmview/collectors/storage.py +++ b/dsmview/collectors/storage.py @@ -51,7 +51,9 @@ class StorageCollector(Collector[StorageSnapshot]): if len(parts) < 6: continue 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 if device in ("tmpfs", "devtmpfs", "overlay", "none"): continue diff --git a/dsmview/collectors/system.py b/dsmview/collectors/system.py index 01f94d5..2ed8098 100644 --- a/dsmview/collectors/system.py +++ b/dsmview/collectors/system.py @@ -67,7 +67,8 @@ class SystemCollector(Collector[SystemSnapshot]): "cat /proc/cpuinfo", "cat /proc/uptime", "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", ] results = await self.executor.gather(cmds) @@ -78,8 +79,9 @@ class SystemCollector(Collector[SystemSnapshot]): self._parse_cpuinfo(results[3].stdout, snap) self._parse_uptime(results[4].stdout, snap) snap.hostname = results[5].stdout.strip() - self._parse_synoinfo(results[6].stdout, snap) - self._parse_temp(results[7].stdout, snap) + self._parse_version(results[6].stdout, snap) + self._parse_synoinfo(results[7].stdout, snap) + self._parse_temp(results[8].stdout, snap) return snap @staticmethod @@ -154,6 +156,24 @@ class SystemCollector(Collector[SystemSnapshot]): except ValueError: 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 def _parse_synoinfo(text: str, snap: SystemSnapshot) -> None: for line in text.splitlines(): @@ -161,10 +181,6 @@ class SystemCollector(Collector[SystemSnapshot]): value = value.strip().strip('"') if key == "upnpmodelname": 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 def _parse_temp(text: str, snap: SystemSnapshot) -> None: diff --git a/dsmview/ui/app.py b/dsmview/ui/app.py index 1ee8739..e602983 100644 --- a/dsmview/ui/app.py +++ b/dsmview/ui/app.py @@ -1,15 +1,10 @@ from __future__ import annotations import asyncio -import time from datetime import datetime -from typing import Optional -from textual import on, work from textual.app import App, ComposeResult from textual.binding import Binding -from textual.containers import Container -from textual.screen import ModalScreen from textual.widgets import Footer, Static, TabbedContent, TabPane from dsmview.collectors import ( @@ -23,37 +18,14 @@ from dsmview.collectors import ( from dsmview.ssh.connection import NasConnection from dsmview.ssh.executor import Executor 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 - - -class ConfirmDialog(ModalScreen[bool]): - """Y/N confirmation modal for destructive actions.""" - - 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) +# 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 +REFRESH_MED = 5.0 +REFRESH_SLOW = 30.0 class DsmviewApp(App): @@ -65,14 +37,17 @@ class DsmviewApp(App): Binding("r", "refresh_now", "Refresh"), Binding("f1", "switch_tab('dashboard')", "Dash"), Binding("f2", "switch_tab('logs')", "Logs"), - Binding("f3", "switch_tab('services')", "Services"), - Binding("f4", "switch_tab('disks')", "Disks"), + Binding("a", "filter('ALL')", "All", show=False), + 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: super().__init__() self.connection = connection - self.executor = Executor(connection, max_workers=6) + self.executor = Executor(connection, max_workers=8) self.system = SystemCollector(self.executor) self.storage = StorageCollector(self.executor) self.disks = DisksCollector(self.executor) @@ -80,7 +55,6 @@ class DsmviewApp(App): self.logs = LogCollector(self.executor) self.services = ServicesCollector(self.executor) self._dsm_label: str = "—" - self._refresh_lock = asyncio.Lock() self._tail_workers: list = [] def compose(self) -> ComposeResult: @@ -93,17 +67,13 @@ class DsmviewApp(App): with TabPane("F2 Logs", id="logs"): self.logs_tab = LogsTab() 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() def on_mount(self) -> None: 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.action_refresh_now() self._start_log_tails() @@ -118,44 +88,63 @@ class DsmviewApp(App): self.connection.close() def action_switch_tab(self, tab_id: str) -> None: - tabbed = self.query_one(TabbedContent) - tabbed.active = tab_id + self.query_one(TabbedContent).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: - 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: - async with self._refresh_lock: - try: - system_task = asyncio.create_task(self.system.collect()) - storage_task = asyncio.create_task(self.storage.collect()) - network_task = asyncio.create_task(self.network.collect()) - services_task = asyncio.create_task(self.services.collect()) - disks_task = asyncio.create_task(self.disks.collect()) + def _tick_fast(self) -> None: + self.run_worker(self._refresh_fast(), exclusive=True, group="refresh-fast") - 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: - self.notify(f"Refresh error: {e}", severity="error", timeout=5) - return + def _tick_medium(self) -> None: + self.run_worker(self._refresh_medium(), exclusive=True, group="refresh-med") - if not self._dsm_label or self._dsm_label == "—": - self._dsm_label = self._make_dsm_label(system_snap) - self._update_topbar() + def _tick_slow(self) -> None: + self.run_worker(self._refresh_slow(), exclusive=True, group="refresh-slow") - self.dashboard.update_system(system_snap) - self.dashboard.update_storage(storage_snap) - self.dashboard.update_network(net_snap) - self.services_tab.update_services(services_snap.services) - self.disks_tab.update_disks(disks_snap) - self.disks_tab.update_raid(storage_snap) + async def _refresh_fast(self) -> None: + try: + system_snap, net_snap = await asyncio.gather( + self.system.collect(), + self.network.collect(), + ) + except Exception as e: + self.notify(f"fast refresh error: {e}", severity="error", timeout=4) + return + self.dashboard.update_system(system_snap) + self.dashboard.update_network(net_snap) + if self._dsm_label == "—": + self._dsm_label = self._make_dsm_label(system_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: 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) self._tail_workers.append(w) @@ -164,7 +153,7 @@ class DsmviewApp(App): initial = await self.logs.collect() self.logs_tab.push_initial(initial) 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: try: @@ -173,25 +162,6 @@ class DsmviewApp(App): except Exception: 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: now = datetime.now().strftime("%H:%M:%S") target = self.connection.target.label diff --git a/dsmview/ui/tabs/__init__.py b/dsmview/ui/tabs/__init__.py index 5662d02..42e0223 100644 --- a/dsmview/ui/tabs/__init__.py +++ b/dsmview/ui/tabs/__init__.py @@ -1,6 +1,4 @@ from dsmview.ui.tabs.dashboard import DashboardTab 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"] diff --git a/dsmview/ui/tabs/dashboard.py b/dsmview/ui/tabs/dashboard.py index 0b8b2f4..e662dd7 100644 --- a/dsmview/ui/tabs/dashboard.py +++ b/dsmview/ui/tabs/dashboard.py @@ -1,14 +1,18 @@ from __future__ import annotations -from textual.app import ComposeResult -from textual.containers import Container, Horizontal, Vertical -from textual.widgets import Static +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, MeterBar +from dsmview.ui.widgets import BrailleGraph def _fmt_bytes_per_sec(b: float) -> str: @@ -30,87 +34,152 @@ def _fmt_uptime(s: float) -> str: return f"{hours:02d}h {minutes:02d}m" -class DashboardTab(Container): +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: - with Container(id="dashboard-grid"): - with Vertical(id="cpu-panel", classes="panel"): - 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 + # 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 Vertical(id="storage-panel", classes="panel"): - yield Static("STORAGE", classes="panel-title") - self.storage_body = Static("") - yield self.storage_body + with Container(classes="panel"): + yield Static("STORAGE", classes="panel-title") + self.storage_body = Static("") + yield self.storage_body - with Vertical(id="mem-panel", 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 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 Vertical(id="net-panel", classes="panel"): - yield Static("NETWORK", classes="panel-title") - self.net_summary = Static("") - yield self.net_summary - with Horizontal(): - self.net_rx_graph = BrailleGraph(color="magenta") - self.net_tx_graph = BrailleGraph(color="blue") - 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: - self.cpu_meter.update(s.cpu_percent) - self.cpu_graph.push(s.cpu_percent) - temp = f" temp {s.temperature_c:.0f}°C" if s.temperature_c is not None else "" - self.cpu_summary.update( - f"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}" + 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.mem_meter.update(s.mem_percent) - used_mb = s.mem_used_kb / 1024 - total_mb = s.mem_total_kb / 1024 - cached_mb = s.mem_cached_kb / 1024 - self.mem_summary.update( - f"used {used_mb:7.0f} MiB / total {total_mb:7.0f} MiB cached {cached_mb:6.0f} MiB" + 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: - bar_w = 20 - filled = int(round(bar_w * v.percent / 100.0)) color = ( theme.CRITICAL if v.percent >= 90 else theme.WARNING if v.percent >= 75 else theme.DISK_BAR ) - bar = f"[{color}]" + "█" * filled + "[/]" + "░" * (bar_w - filled) + bar = _inline_bar(v.percent, 24, color) 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: - lines.append("(no volumes detected)") - for r in s.raids: - lines.append(f" RAID {r.name} {r.level} {r.state}") + 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)") + self.net_summary.update(" (no interface)") return self.net_rx_graph.push(primary.rx_bps) self.net_tx_graph.push(primary.tx_bps) 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_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 "—", + ) diff --git a/dsmview/ui/tabs/disks.py b/dsmview/ui/tabs/disks.py deleted file mode 100644 index 22f8862..0000000 --- a/dsmview/ui/tabs/disks.py +++ /dev/null @@ -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)) diff --git a/dsmview/ui/tabs/logs.py b/dsmview/ui/tabs/logs.py index d59e8b2..42820c0 100644 --- a/dsmview/ui/tabs/logs.py +++ b/dsmview/ui/tabs/logs.py @@ -4,8 +4,7 @@ from collections import deque from typing import Deque from textual.app import ComposeResult -from textual.binding import Binding -from textual.containers import Container, Horizontal +from textual.containers import Container from textual.widgets import RichLog, Static from dsmview.collectors.logs import LogLine, Severity @@ -16,13 +15,9 @@ _MAX_LINES = 2000 class LogsTab(Container): - BINDINGS = [ - Binding("a", "filter('ALL')", "ALL", show=False), - Binding("1", "filter('ERROR')", "ERROR", show=False), - Binding("2", "filter('WARN')", "WARN", show=False), - Binding("3", "filter('SECURITY')", "SECURITY", show=False), - Binding("4", "filter('INFO')", "INFO", show=False), - ] + """Live log tail with severity filter. The filter keys (a / 1-4) are + bound at the App level — pressing them anywhere switches to this tab + and applies the filter.""" def __init__(self) -> None: super().__init__() @@ -31,13 +26,19 @@ class LogsTab(Container): def compose(self) -> ComposeResult: 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") 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 - def action_filter(self, name: str) -> None: + def set_filter(self, name: str) -> None: self._filter = name self.status_bar.update(f"filter: {name}") self._rerender() diff --git a/dsmview/ui/tabs/services.py b/dsmview/ui/tabs/services.py deleted file mode 100644 index 4be6b6a..0000000 --- a/dsmview/ui/tabs/services.py +++ /dev/null @@ -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) diff --git a/dsmview/ui/theme.py b/dsmview/ui/theme.py index ca7755a..a210aa5 100644 --- a/dsmview/ui/theme.py +++ b/dsmview/ui/theme.py @@ -27,11 +27,6 @@ Screen {{ color: {BORDER_TITLE}; }} -Header {{ - background: {BG}; - color: {BORDER_TITLE}; -}} - Footer {{ background: {BG}; color: {BORDER_TITLE}; @@ -68,52 +63,24 @@ Tab.-active {{ background: {BG}; color: {BORDER_TITLE}; padding: 0 1; + margin-bottom: 1; + height: auto; }} -.panel > .panel-title {{ +.panel > Static.panel-title {{ color: {BORDER_TITLE}; text-style: bold; + height: 1; }} -.meter-cpu Bar > .bar--bar {{ - color: {CPU_BAR}; -}} - -.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}; +#net-graphs {{ + height: 5; }} DataTable {{ background: {BG}; color: {BORDER_TITLE}; + height: auto; }} DataTable > .datatable--header {{ @@ -121,43 +88,4 @@ DataTable > .datatable--header {{ color: {BORDER_TITLE}; 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; -}} """ diff --git a/dsmview/ui/widgets/__init__.py b/dsmview/ui/widgets/__init__.py index d87b231..f203f8c 100644 --- a/dsmview/ui/widgets/__init__.py +++ b/dsmview/ui/widgets/__init__.py @@ -1,5 +1,4 @@ from dsmview.ui.widgets.braille_graph import BrailleGraph from dsmview.ui.widgets.meter_bar import MeterBar -from dsmview.ui.widgets.service_list import ServiceList -__all__ = ["BrailleGraph", "MeterBar", "ServiceList"] +__all__ = ["BrailleGraph", "MeterBar"] diff --git a/dsmview/ui/widgets/service_list.py b/dsmview/ui/widgets/service_list.py deleted file mode 100644 index 6dfafee..0000000 --- a/dsmview/ui/widgets/service_list.py +++ /dev/null @@ -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) diff --git a/pyproject.toml b/pyproject.toml index e7aff44..ce4d7e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "dsmview" -version = "0.1.0" +version = "0.2.0" description = "TUI SSH monitor for Synology DSM 7.x" readme = "README.md" license = { text = "MIT" }