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>[^\]]+)\]")
|
_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()
|
||||||
|
|
|
||||||
|
|
@ -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]):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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 "—",
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 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()
|
||||||
|
|
|
||||||
|
|
@ -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};
|
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;
|
|
||||||
}}
|
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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]
|
[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" }
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue