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:
parent
3fe7cff45e
commit
b266bcb605
16 changed files with 322 additions and 430 deletions
|
|
@ -1 +1 @@
|
|||
__version__ = "0.1.0"
|
||||
__version__ = "0.2.0"
|
||||
|
|
|
|||
|
|
@ -8,11 +8,15 @@ from dsmview.collectors.base import Collector
|
|||
|
||||
_SIZE_RE = re.compile(r"User Capacity:\s+[\d,]+\s+bytes\s+\[(?P<size>[^\]]+)\]")
|
||||
_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+)")
|
||||
_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()
|
||||
|
|
|
|||
|
|
@ -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]):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
def _tick_fast(self) -> None:
|
||||
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:
|
||||
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())
|
||||
|
||||
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
|
||||
system_snap, net_snap = await asyncio.gather(
|
||||
self.system.collect(),
|
||||
self.network.collect(),
|
||||
)
|
||||
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
|
||||
|
||||
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_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)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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 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
|
||||
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)
|
||||
|
||||
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")
|
||||
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 Vertical(id="net-panel", classes="panel"):
|
||||
with Container(classes="panel"):
|
||||
yield Static("NETWORK", classes="panel-title")
|
||||
self.net_summary = Static("")
|
||||
yield self.net_summary
|
||||
with Horizontal():
|
||||
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:
|
||||
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)")
|
||||
lines.append(" (no volumes detected)")
|
||||
if s.raids:
|
||||
lines.append("")
|
||||
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))
|
||||
|
||||
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 "—",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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;
|
||||
}}
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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" }
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue