Compare commits

..

No commits in common. "main" and "v0.1.0" have entirely different histories.
main ... v0.1.0

20 changed files with 475 additions and 889 deletions

View file

@ -155,14 +155,11 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
# Stash the newly rendered formula before switching branches —
# otherwise `git checkout main` aborts when the working-tree
# formula differs from main's committed version.
cp Formula/dsmview.rb /tmp/dsmview.rb
git config user.email "github-actions[bot]@users.noreply.github.com" git config user.email "github-actions[bot]@users.noreply.github.com"
git config user.name "github-actions[bot]" git config user.name "github-actions[bot]"
git fetch origin main git fetch origin main
git checkout -f main git checkout main
cp Formula/dsmview.rb /tmp/dsmview.rb
git pull --rebase origin main || true git pull --rebase origin main || true
cp /tmp/dsmview.rb Formula/dsmview.rb cp /tmp/dsmview.rb Formula/dsmview.rb
git add Formula/dsmview.rb git add Formula/dsmview.rb

View file

@ -3,113 +3,42 @@ class Dsmview < Formula
desc "TUI SSH monitor for Synology DSM 7.x" desc "TUI SSH monitor for Synology DSM 7.x"
homepage "https://github.com/janvete/dsmview" homepage "https://github.com/janvete/dsmview"
url "https://github.com/janvete/dsmview/archive/refs/tags/v0.3.1.tar.gz" url "https://github.com/janvete/dsmview/archive/refs/tags/v0.1.0.tar.gz"
sha256 "6f0fcda846ee254604b87e06f3eaf20f58cdb148573cc30cb0fc7f31e922d665" sha256 "0000000000000000000000000000000000000000000000000000000000000000"
license "MIT" license "MIT"
head "https://github.com/janvete/dsmview.git", branch: "main" head "https://github.com/janvete/dsmview.git", branch: "main"
depends_on "python@3.12" depends_on "python@3.12"
depends_on "rust" => :build depends_on "rust" => :build # cryptography wheels build dep on some platforms
resource "PyNaCl" do # PYTHON_RESOURCES_BEGIN
url "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz" # The release workflow regenerates the resource blocks below using
sha256 "018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c" # homebrew-pypi-poet whenever a new tag is pushed.
end resource "textual" do
url "https://files.pythonhosted.org/packages/source/t/textual/textual-0.86.3.tar.gz"
resource "Pygments" do sha256 "0000000000000000000000000000000000000000000000000000000000000000"
url "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz"
sha256 "6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"
end
resource "bcrypt" do
url "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz"
sha256 "f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd"
end
resource "cffi" do
url "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz"
sha256 "44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"
end
resource "click" do
url "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz"
sha256 "918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96"
end
resource "cryptography" do
url "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz"
sha256 "f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493"
end
resource "invoke" do
url "https://files.pythonhosted.org/packages/33/f6/227c48c5fe47fa178ccf1fda8f047d16c97ba926567b661e9ce2045c600c/invoke-3.0.3.tar.gz"
sha256 "437b6a622223824380bfb4e64f612711a6b648c795f565efc8625af66fb57f0c"
end
resource "linkify-it-py" do
url "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz"
sha256 "43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b"
end
resource "markdown-it-py" do
url "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz"
sha256 "04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49"
end
resource "mdit-py-plugins" do
url "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz"
sha256 "a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0"
end
resource "mdurl" do
url "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz"
sha256 "bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"
end end
resource "paramiko" do resource "paramiko" do
url "https://files.pythonhosted.org/packages/62/93/dcc25d52f49022ae6175d15e6bd751f1acc99b98bc61fc55e5155a7be2e7/paramiko-5.0.0.tar.gz" url "https://files.pythonhosted.org/packages/source/p/paramiko/paramiko-3.5.0.tar.gz"
sha256 "36763b5b95c2a0dcfdf1abc48e48156ee425b21efe2f0e787c2dd5a95c0e5e79" sha256 "0000000000000000000000000000000000000000000000000000000000000000"
end end
resource "platformdirs" do resource "click" do
url "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz" url "https://files.pythonhosted.org/packages/source/c/click/click-8.1.7.tar.gz"
sha256 "31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7" sha256 "0000000000000000000000000000000000000000000000000000000000000000"
end end
resource "plotext" do resource "plotext" do
url "https://files.pythonhosted.org/packages/c9/d7/f75f397af966fe252d0d34ffd3cae765317fce2134f925f95e7d6725d1ce/plotext-5.3.2.tar.gz" url "https://files.pythonhosted.org/packages/source/p/plotext/plotext-5.3.2.tar.gz"
sha256 "52d1e932e67c177bf357a3f0fe6ce14d1a96f7f7d5679d7b455b929df517068e" sha256 "0000000000000000000000000000000000000000000000000000000000000000"
end
resource "pycparser" do
url "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz"
sha256 "600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"
end
resource "rich" do
url "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz"
sha256 "edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36"
end
resource "textual" do
url "https://files.pythonhosted.org/packages/9b/7a/c519db0aba5024f86e71e9631810bfdd6866ed2c8695bd7fa34b90e7ef59/textual-8.2.7.tar.gz"
sha256 "658f568ff81e30ed43890c3e07520390e5cf1b4763822006e060656b0a88f105"
end end
resource "textual-plotext" do resource "textual-plotext" do
url "https://files.pythonhosted.org/packages/9a/b0/e4e0f38df057db778252db0dd2c08522d7222b8537b6a0181d797b9044bd/textual_plotext-1.0.1.tar.gz" url "https://files.pythonhosted.org/packages/source/t/textual-plotext/textual_plotext-0.2.1.tar.gz"
sha256 "836f53a3316756609e194129a35c2875638e7958c261f541e0a794f7c98011be" sha256 "0000000000000000000000000000000000000000000000000000000000000000"
end
resource "typing_extensions" do
url "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz"
sha256 "0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"
end
resource "uc-micro-py" do
url "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz"
sha256 "c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811"
end end
# PYTHON_RESOURCES_END
def install def install
virtualenv_install_with_resources virtualenv_install_with_resources

View file

@ -2,31 +2,11 @@
TUI SSH monitor for Synology DSM 7.x — read-only dashboard inspired by `btop`. TUI SSH monitor for Synology DSM 7.x — read-only dashboard inspired by `btop`.
## Installation ```
brew tap janvete/tools
### macOS (Homebrew)
```bash
brew tap janvete/tools https://git.janvete.com/seth/homebrew-tools.git
brew install dsmview brew install dsmview
``` ```
### Debian / Ubuntu (APT)
```bash
echo "deb [trusted=yes] https://git.janvete.com/seth/apt/raw/branch/main ./" | sudo tee /etc/apt/sources.list.d/janvete.list
sudo apt update
sudo apt install dsmview
```
### From source
```bash
git clone ssh://git@git-ssh.janvete.com:554/seth/dsmview.git
cd dsmview
pip install .
```
## Usage ## Usage
``` ```
@ -49,28 +29,20 @@ service — and that requires explicit confirmation in the UI.
## Tabs ## Tabs
- **F1 Dashboard** — CPU history graph, memory, storage pools, network - **F1 Dashboard** — CPU history graph, memory, storage pools, network
throughput, load average, temperatures, services summary and disk health. throughput, load average, temperatures.
- **F2 Logs** — live tail of system, auth and package logs (including - **F2 Logs** — live tail of `/var/log/messages` and `/var/log/auth.log`
Active Backup) with severity filters and text search. with severity filters (ALL / ERROR / WARN / SECURITY / INFO).
- **F3 Services** — `systemctl` service list with up/down status and - **F3 Services** — `synoservicectl --list` table with up/down status and
start/stop/restart actions. restart/stop/start actions.
- **F4 Disks** — per-disk model/size/temp/health from `smartctl` plus RAID
status from `/proc/mdstat`.
## Keys ## Keys
``` ```
F1..F3 switch tab F1..F4 switch tab
r refresh now r refresh now
q quit q quit
/ focus log search
ESC clear log search
Logs tab:
a / 1-4 severity filter (All / Error / Warn / Security / Info)
Services tab:
↑/↓ select service
s start
S stop
R restart
(or use the Start / Stop / Restart buttons)
``` ```
## Requirements ## Requirements

View file

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

View file

@ -8,15 +8,11 @@ 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>.+)")
_VENDOR_RE = re.compile(r"Vendor:\s+(?P<vendor>\S+)") _TEMP_RE = re.compile(r"(?:Temperature_Celsius|Airflow_Temperature_Cel|Current Temperature)[^\d]+(\d+)")
_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+)(?:\s|$)", re.MULTILINE) _REALLOC_RE = re.compile(r"Reallocated_Sector_Ct\s+.*?\s(\d+)$", re.MULTILINE)
_PENDING_RE = re.compile(r"Current_Pending_Sector\s+.*?\s(\d+)(?:\s|$)", 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+)(?:\s|$)", re.MULTILINE) _UNCORR_RE = re.compile(r"Offline_Uncorrectable\s+.*?\s(\d+)$", re.MULTILINE)
@dataclass(slots=True) @dataclass(slots=True)
@ -41,27 +37,20 @@ class DisksCollector(Collector[DisksSnapshot]):
name = "disks" name = "disks"
async def collect(self) -> DisksSnapshot: async def collect(self) -> DisksSnapshot:
# Synology exposes drives as /dev/sata{N} (newer models) or listing = await self.executor.run("ls /dev/sd[a-z] 2>/dev/null")
# /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/") and "p" not in line.rsplit("/", 1)[-1]: if line.startswith("/dev/sd") and len(line) == 8:
devices.append(line) devices.append(line)
snap = DisksSnapshot() snap = DisksSnapshot()
if not devices: if not devices:
return snap return snap
# `-d sat` is needed on Synology because drives are presented via a smart_cmds = [f"smartctl -a {d} 2>/dev/null || true" for d in devices]
# SCSI shim. Without it, smartctl reports "SMART unavailable". health_cmds = [f"smartctl -H {d} 2>/dev/null || true" for d in devices]
smart_cmds = [f"smartctl -a -d sat {d} 2>&1 || smartctl -a {d} 2>&1 || true" for d in devices] smart_results = await self.executor.gather(smart_cmds, timeout=15.0)
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):
@ -78,12 +67,6 @@ class DisksCollector(Collector[DisksSnapshot]):
m = _MODEL_RE.search(text) m = _MODEL_RE.search(text)
if m: if m:
info.model = m.group("model").strip() info.model = m.group("model").strip()
else:
# Enterprise drives often report as SCSI (Vendor:/Product:).
vendor = _VENDOR_RE.search(text)
product = _PRODUCT_RE.search(text)
if vendor and product:
info.model = f"{vendor.group('vendor').strip()} {product.group('product').strip()}"
m = _SIZE_RE.search(text) m = _SIZE_RE.search(text)
if m: if m:
info.size = m.group("size").strip() info.size = m.group("size").strip()

View file

@ -16,36 +16,10 @@ class Severity(str, Enum):
OTHER = "OTHER" OTHER = "OTHER"
_ERROR_RE = re.compile( _ERROR_RE = re.compile(r"\b(error|fail(?:ed|ure)?|critical|EXT4-fs error|I/O error|RAID degraded|panic|oom)\b", re.IGNORECASE)
r"\b(error|fail(?:ed|ure)?|critical|EXT4-fs error|I/O error|RAID degraded|" _WARN_RE = re.compile(r"\b(warn(?:ing)?|temperature|fan speed|link down|throttle)\b", re.IGNORECASE)
r"panic|oom|crashed|corrupt|cannot|unable)\b", _SECURITY_RE = re.compile(r"\b(Failed password|Invalid user|authentication failure|sudo|session opened|session closed)\b")
re.IGNORECASE, _INFO_RE = re.compile(r"\b(completed|started|stopped|success|updated|finished)\b", re.IGNORECASE)
)
_WARN_RE = re.compile(
r"\b(warn(?:ing)?|temperature|fan speed|link down|throttle|offline)\b",
re.IGNORECASE,
)
_SECURITY_RE = re.compile(
r"\b(Failed password|Invalid user|authentication failure|sudo|"
r"session opened|session closed)\b"
)
_INFO_RE = re.compile(
r"\b(completed|started|stopped|success|updated|finished|backup task)\b",
re.IGNORECASE,
)
# Try to strip leading syslog timestamp so we can show a short date prefix.
_SYSLOG_TS_RE = re.compile(
r"^([A-Z][a-z]{2}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}|"
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[\+\-]\d{2}:\d{2}|"
r"\d{4}/\d{2}/\d{2}\s+\d{2}:\d{2}:\d{2})"
)
@dataclass(slots=True)
class LogSource:
path: str
label: str
@dataclass(slots=True) @dataclass(slots=True)
@ -53,8 +27,6 @@ class LogLine:
raw: str raw: str
source: str source: str
severity: Severity = Severity.OTHER severity: Severity = Severity.OTHER
timestamp: str = ""
message: str = ""
@classmethod @classmethod
def classify(cls, raw: str, source: str) -> "LogLine": def classify(cls, raw: str, source: str) -> "LogLine":
@ -69,47 +41,29 @@ class LogLine:
sev = Severity.WARN sev = Severity.WARN
elif _INFO_RE.search(raw): elif _INFO_RE.search(raw):
sev = Severity.INFO sev = Severity.INFO
return cls(raw=raw, source=source, severity=sev)
# Extract a leading timestamp, if present.
ts_match = _SYSLOG_TS_RE.match(raw)
timestamp = ts_match.group(1) if ts_match else ""
message = raw[ts_match.end() :].lstrip() if ts_match else raw
return cls(
raw=raw,
source=source,
severity=sev,
timestamp=timestamp,
message=message,
)
class LogCollector(Collector[List[LogLine]]): class LogCollector(Collector[List[LogLine]]):
name = "logs" name = "logs"
# (path, short_label). Paths are checked at runtime so missing files SOURCES = (
# simply yield no lines. ("/var/log/messages", "messages"),
SOURCES: tuple[LogSource, ...] = ( ("/var/log/auth.log", "auth"),
LogSource("/var/log/messages", "messages"),
LogSource("/var/log/auth.log", "auth"),
LogSource("/var/log/packages/ActiveBackup.log", "abb-pkg"),
# The ABB activity log is usually under /volumeX/@ActiveBackup/log.
# The package exposes a symlink at /var/packages/ActiveBackup/target/log.
LogSource("/var/packages/ActiveBackup/target/log/activity.log", "abb-activity"),
) )
async def collect(self) -> List[LogLine]: async def collect(self) -> List[LogLine]:
cmds = [f"tail -n 200 {src.path} 2>/dev/null || true" for src in self.SOURCES] cmds = [f"tail -n 200 {path} 2>/dev/null || true" for path, _ in self.SOURCES]
results = await self.executor.gather(cmds, timeout=10.0) results = await self.executor.gather(cmds, timeout=10.0)
lines: List[LogLine] = [] lines: List[LogLine] = []
for src, res in zip(self.SOURCES, results): for (_, source), res in zip(self.SOURCES, results):
for raw in res.stdout.splitlines(): for raw in res.stdout.splitlines():
if raw.strip(): if raw.strip():
lines.append(LogLine.classify(raw, src.label)) lines.append(LogLine.classify(raw, source))
# Newest last so the RichLog scrolls to the bottom naturally.
return lines return lines
async def tail(self, source_index: int = 0) -> AsyncIterator[LogLine]: async def tail(self, source_index: int = 0) -> AsyncIterator[LogLine]:
src = self.SOURCES[source_index] path, source = self.SOURCES[source_index]
async for raw in self.executor.tail(src.path): async for raw in self.executor.tail(path):
if raw.strip(): if raw.strip():
yield LogLine.classify(raw, src.label) yield LogLine.classify(raw, source)

View file

@ -1,22 +1,11 @@
from __future__ import annotations from __future__ import annotations
import re
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Dict, List, Optional from typing import Dict, List, Optional
from dsmview.collectors.base import Collector from dsmview.collectors.base import Collector
# Interface prefixes we never want to surface — virtual, internal,
# bridge, container, VPN.
_HIDE_PREFIXES = (
"lo", "docker", "veth", "br-", "virbr", "tap", "tun",
"sit", "ovs-system", "dummy", "wg",
)
# Open vSwitch on Synology exposes the real NIC twice — as `ethN` and
# `ovs_ethN`. The ovs_ wrapper is the one with the IP, so prefer it.
_OVS_RE = re.compile(r"^ovs_(eth\d+)$")
@dataclass(slots=True) @dataclass(slots=True)
class IfaceCounters: class IfaceCounters:
@ -32,7 +21,6 @@ class NetSample:
tx_bps: float = 0.0 tx_bps: float = 0.0
rx_total: int = 0 rx_total: int = 0
tx_total: int = 0 tx_total: int = 0
ip: str = ""
@dataclass(slots=True) @dataclass(slots=True)
@ -40,14 +28,10 @@ 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 accumulated traffic. Falls for s in self.samples:
back to the first sample if no counters have moved yet.""" if s.iface in ("eth0", "ovs_eth0", "bond0"):
if not self.samples: return s
return None return self.samples[0] if self.samples else 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)
return self.samples[0]
class NetworkCollector(Collector[NetworkSnapshot]): class NetworkCollector(Collector[NetworkSnapshot]):
@ -58,38 +42,18 @@ class NetworkCollector(Collector[NetworkSnapshot]):
self._prev: Dict[str, IfaceCounters] = {} self._prev: Dict[str, IfaceCounters] = {}
async def collect(self) -> NetworkSnapshot: async def collect(self) -> NetworkSnapshot:
netdev, ipaddr = await self.executor.gather([ result = await self.executor.run("cat /proc/net/dev")
"cat /proc/net/dev", return self._parse(result.stdout)
"ip -o -4 addr show 2>/dev/null || true",
])
ips = self._parse_ip_addrs(ipaddr.stdout)
return self._merge(netdev.stdout, ips)
@staticmethod def _parse(self, text: str) -> NetworkSnapshot:
def _parse_ip_addrs(text: str) -> Dict[str, str]:
"""Map interface name → first IPv4 address."""
out: Dict[str, str] = {}
for line in text.splitlines():
parts = line.split()
if len(parts) < 4 or parts[2] != "inet":
continue
iface = parts[1]
cidr = parts[3]
addr = cidr.split("/", 1)[0]
out.setdefault(iface, addr)
return out
def _merge(self, netdev_text: str, ips: Dict[str, str]) -> NetworkSnapshot:
snap = NetworkSnapshot() snap = NetworkSnapshot()
now = time.monotonic() now = time.monotonic()
# First pass: read counters for all visible non-hidden interfaces. for line in text.splitlines():
candidates: Dict[str, NetSample] = {}
for line in netdev_text.splitlines():
if ":" not in line: if ":" not in line:
continue continue
name, _, rest = line.partition(":") name, _, rest = line.partition(":")
iface = name.strip() iface = name.strip()
if self._is_hidden(iface): if iface in ("lo",) or iface.startswith("docker") or iface.startswith("veth"):
continue continue
fields = rest.split() fields = rest.split()
if len(fields) < 16: if len(fields) < 16:
@ -101,33 +65,12 @@ class NetworkCollector(Collector[NetworkSnapshot]):
continue continue
current = IfaceCounters(rx_bytes=rx, tx_bytes=tx, timestamp=now) current = IfaceCounters(rx_bytes=rx, tx_bytes=tx, timestamp=now)
prev = self._prev.get(iface) prev = self._prev.get(iface)
sample = NetSample(iface=iface, rx_total=rx, tx_total=tx, ip=ips.get(iface, "")) sample = NetSample(iface=iface, rx_total=rx, tx_total=tx)
if prev is not None: if prev is not None:
dt = current.timestamp - prev.timestamp dt = current.timestamp - prev.timestamp
if dt > 0: if dt > 0:
sample.rx_bps = max(0.0, (rx - prev.rx_bytes) / dt) sample.rx_bps = max(0.0, (rx - prev.rx_bytes) / dt)
sample.tx_bps = max(0.0, (tx - prev.tx_bytes) / dt) sample.tx_bps = max(0.0, (tx - prev.tx_bytes) / dt)
candidates[iface] = sample snap.samples.append(sample)
self._prev[iface] = current self._prev[iface] = current
# OvS hides the real NIC behind both `ethN` and `ovs_ethN`. The
# ovs_ entry is what carries the IP; drop the raw ethN twin when
# both exist so we don't show a duplicate row.
for name in list(candidates):
m = _OVS_RE.match(name)
if m and m.group(1) in candidates:
candidates.pop(m.group(1), None)
# Sort: interfaces with an IP first, then by total traffic desc.
snap.samples = sorted(
candidates.values(),
key=lambda s: (0 if s.ip else 1, -(s.rx_total + s.tx_total), s.iface),
)
return snap return snap
@staticmethod
def _is_hidden(iface: str) -> bool:
for prefix in _HIDE_PREFIXES:
if iface == prefix or iface.startswith(prefix):
return True
return False

View file

@ -1,19 +1,19 @@
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:
# Human-readable name shown in the UI.
name: str name: str
# Full systemd unit name, e.g. "pkg-ActiveBackup-logd.service".
unit: str = ""
running: bool = False running: bool = False
description: str = "" status_text: str = ""
@dataclass(slots=True) @dataclass(slots=True)
@ -21,65 +21,52 @@ 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:
# --no-legend keeps the table machine-parseable; --plain removes listing = await self.executor.run("synoservicectl --list 2>/dev/null || true")
# the unicode tree characters that some systemd versions emit. names = self._parse_list(listing.stdout)
result = await self.executor.run( if not names:
"systemctl list-units --type=service --all --no-legend --no-pager --plain 2>/dev/null || true", names = list(self.DEFAULT_SERVICES)
timeout=10.0,
) cmds = [f"synoservicectl --status {n} 2>&1 || true" for n in names]
services = self._parse(result.stdout) results = await self.executor.gather(cmds, timeout=15.0)
return ServicesSnapshot(services=services) 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
@staticmethod @staticmethod
def _parse(text: str) -> List[ServiceInfo]: def _parse_list(text: str) -> List[str]:
out: List[ServiceInfo] = [] out: List[str] = []
for line in text.splitlines(): for line in text.splitlines():
parts = line.split(None, 4) line = line.strip()
if len(parts) < 4: if not line or line.startswith("#"):
continue continue
unit = parts[0] for token in line.split():
# systemctl marks failed/loaded/not-found in column 2; we want if token and token[0].isalpha() and "/" not in token:
# the SUB state in column 4 (running/dead/exited/etc). out.append(token)
sub_state = parts[3] seen: dict[str, None] = {}
desc = parts[4] if len(parts) > 4 else "" for n in out:
if not unit.endswith(".service"): seen[n] = None
continue return list(seen.keys())
name = unit[: -len(".service")]
# Strip pkg- prefix DSM uses for package-installed services so @staticmethod
# smbd shows as smbd, not pkg-smbd. def _is_running(text: str) -> bool:
display = name[4:] if name.startswith("pkg-") else name low = text.lower()
running = sub_state == "running" if "is running" in low or "start/running" in low:
out.append( return True
ServiceInfo( if "is stopped" in low or "stop/waiting" in low:
name=display, return False
unit=unit, return "running" in low and "not" not in low
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

View file

@ -1,13 +1,10 @@
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
_VOLUME_RE = re.compile(r"^/volume\d+$")
@dataclass(slots=True) @dataclass(slots=True)
class VolumeInfo: class VolumeInfo:
@ -54,10 +51,7 @@ 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]
# Only show actual storage pools — /volume1, /volume2, etc. if not mount.startswith("/volume") and not mount.startswith("/"):
# Container Manager bind-mounts (/volume1/@appdata/...) and
# system mounts repeat the same usage and just add noise.
if not _VOLUME_RE.match(mount):
continue continue
if device in ("tmpfs", "devtmpfs", "overlay", "none"): if device in ("tmpfs", "devtmpfs", "overlay", "none"):
continue continue

View file

@ -67,8 +67,7 @@ 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/VERSION 2>/dev/null", "cat /etc/synoinfo.conf 2>/dev/null | grep -E '^(unique|productversion|buildnumber|upnpmodelname)='",
"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)
@ -79,9 +78,8 @@ 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_version(results[6].stdout, snap) self._parse_synoinfo(results[6].stdout, snap)
self._parse_synoinfo(results[7].stdout, snap) self._parse_temp(results[7].stdout, snap)
self._parse_temp(results[8].stdout, snap)
return snap return snap
@staticmethod @staticmethod
@ -156,24 +154,6 @@ 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():
@ -181,6 +161,10 @@ class SystemCollector(Collector[SystemSnapshot]):
value = value.strip().strip('"') value = value.strip().strip('"')
if key == "upnpmodelname": if key == "upnpmodelname":
snap.dsm_model = value snap.dsm_model = value
elif key == "productversion":
snap.dsm_version = value
elif key == "buildnumber" and snap.dsm_version:
snap.dsm_version = f"{snap.dsm_version}-{value}"
@staticmethod @staticmethod
def _parse_temp(text: str, snap: SystemSnapshot) -> None: def _parse_temp(text: str, snap: SystemSnapshot) -> None:

View file

@ -1,10 +1,15 @@
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 (
@ -18,14 +23,37 @@ 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, LogsTab, ServicesTab from dsmview.ui.tabs import DashboardTab, DisksTab, LogsTab, ServicesTab
# Three refresh tiers — fast for live metrics, medium for storage/services, REFRESH_INTERVAL = 10.0
# slow for SMART (smartctl is heavy and runs per disk).
REFRESH_FAST = 1.0
REFRESH_MED = 5.0 class ConfirmDialog(ModalScreen[bool]):
REFRESH_SLOW = 30.0 """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)
class DsmviewApp(App): class DsmviewApp(App):
@ -38,22 +66,13 @@ class DsmviewApp(App):
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("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),
Binding("/", "focus_search", "Search", show=False),
Binding("escape", "clear_search", "ClearSearch", show=False),
Binding("s", "start_service", "Start", show=False),
Binding("S", "stop_service", "Stop", show=False),
Binding("R", "restart_service", "Restart", 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=8) self.executor = Executor(connection, max_workers=6)
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)
@ -61,6 +80,7 @@ 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:
@ -74,15 +94,16 @@ class DsmviewApp(App):
self.logs_tab = LogsTab() self.logs_tab = LogsTab()
yield self.logs_tab yield self.logs_tab
with TabPane("F3 Services", id="services"): with TabPane("F3 Services", id="services"):
self.services_tab = ServicesTab(self.executor) self.services_tab = ServicesTab(on_action=self._handle_service_action)
yield self.services_tab 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_FAST, self._tick_fast) self.set_interval(REFRESH_INTERVAL, self.action_refresh_now)
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()
@ -97,88 +118,44 @@ 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:
self.query_one(TabbedContent).active = tab_id tabbed = self.query_one(TabbedContent)
if tab_id == "services": tabbed.active = tab_id
self.services_tab.focus_table()
def action_filter(self, name: str) -> None:
self.query_one(TabbedContent).active = "logs"
self.logs_tab.set_filter(name)
def action_focus_search(self) -> None:
self.query_one(TabbedContent).active = "logs"
self.logs_tab.show_search()
def action_clear_search(self) -> None:
self.logs_tab.hide_search()
def _on_services_tab(self, action_name: str) -> None:
active = self.query_one(TabbedContent).active
if active != "services":
return
getattr(self.services_tab, action_name)()
def action_start_service(self) -> None:
self._on_services_tab("action_start_service")
def action_stop_service(self) -> None:
self._on_services_tab("action_stop_service")
def action_restart_service(self) -> None:
self._on_services_tab("action_restart_service")
def action_refresh_now(self) -> None: def action_refresh_now(self) -> None:
self._tick_fast() self.run_worker(self._refresh_all(), exclusive=True, group="refresh")
self._tick_medium()
self._tick_slow()
def _tick_fast(self) -> None: async def _refresh_all(self) -> None:
self.run_worker(self._refresh_fast(), exclusive=True, group="refresh-fast") async with self._refresh_lock:
try:
system_task = asyncio.create_task(self.system.collect())
storage_task = asyncio.create_task(self.storage.collect())
network_task = asyncio.create_task(self.network.collect())
services_task = asyncio.create_task(self.services.collect())
disks_task = asyncio.create_task(self.disks.collect())
def _tick_medium(self) -> None: system_snap = await system_task
self.run_worker(self._refresh_medium(), exclusive=True, group="refresh-med") storage_snap = await storage_task
net_snap = await network_task
services_snap = await services_task
disks_snap = await disks_task
except Exception as e:
self.notify(f"Refresh error: {e}", severity="error", timeout=5)
return
def _tick_slow(self) -> None: if not self._dsm_label or self._dsm_label == "—":
self.run_worker(self._refresh_slow(), exclusive=True, group="refresh-slow") self._dsm_label = self._make_dsm_label(system_snap)
self._update_topbar()
async def _refresh_fast(self) -> None: self.dashboard.update_system(system_snap)
try: self.dashboard.update_storage(storage_snap)
system_snap, net_snap = await asyncio.gather( self.dashboard.update_network(net_snap)
self.system.collect(), self.services_tab.update_services(services_snap.services)
self.network.collect(), self.disks_tab.update_disks(disks_snap)
) self.disks_tab.update_raid(storage_snap)
except Exception as e:
self.notify(f"fast refresh error: {e}", severity="error", timeout=4)
return
self.dashboard.update_system(system_snap)
self.dashboard.update_network(net_snap)
if self._dsm_label == "—":
self._dsm_label = self._make_dsm_label(system_snap)
async def _refresh_medium(self) -> None:
try:
storage_snap, services_snap = await asyncio.gather(
self.storage.collect(),
self.services.collect(),
)
except Exception as e:
self.notify(f"storage/services error: {e}", severity="warning", timeout=4)
return
self.dashboard.update_storage(storage_snap)
self.dashboard.update_services(services_snap.services)
self.services_tab.update(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 in range(len(self.logs.SOURCES)): for i, (path, source) in enumerate(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)
@ -187,7 +164,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:
@ -196,6 +173,25 @@ class DsmviewApp(App):
except Exception: except Exception:
return return
def _handle_service_action(self, action: str, name: str) -> None:
def on_result(ok: bool | None) -> None:
if ok:
self.run_worker(self._run_service_action(action, name), exclusive=False, group="svc")
self.push_screen(
ConfirmDialog(action, name, self.connection.target.label),
on_result,
)
async def _run_service_action(self, action: str, name: str) -> None:
flag = {"restart": "--restart", "stop": "--stop", "start": "--start"}[action]
result = await self.executor.run(f"synoservicectl {flag} {name}")
if result.ok:
self.notify(f"{action} {name}: OK")
else:
self.notify(f"{action} {name} failed: {result.stderr or result.stdout}", severity="error")
await self._refresh_all()
def _update_topbar(self) -> None: def _update_topbar(self) -> None:
now = datetime.now().strftime("%H:%M:%S") now = datetime.now().strftime("%H:%M:%S")
target = self.connection.target.label target = self.connection.target.label

View file

@ -1,5 +1,6 @@
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.services import ServicesTab
from dsmview.ui.tabs.disks import DisksTab
__all__ = ["DashboardTab", "LogsTab", "ServicesTab"] __all__ = ["DashboardTab", "LogsTab", "ServicesTab", "DisksTab"]

View file

@ -1,18 +1,14 @@
from __future__ import annotations from __future__ import annotations
from typing import Iterable
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical, VerticalScroll from textual.containers import Container, Horizontal, Vertical
from textual.widgets import DataTable, Static from textual.widgets import 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 from dsmview.ui.widgets import BrailleGraph, MeterBar
def _fmt_bytes_per_sec(b: float) -> str: def _fmt_bytes_per_sec(b: float) -> str:
@ -34,163 +30,87 @@ def _fmt_uptime(s: float) -> str:
return f"{hours:02d}h {minutes:02d}m" return f"{hours:02d}h {minutes:02d}m"
def _inline_bar(percent: float, width: int, color: str) -> str: class DashboardTab(Container):
percent = max(0.0, min(100.0, percent))
filled = int(round(width * percent / 100.0))
return f"[{color}]" + "█" * filled + "[/]" + "░" * (width - filled)
class DashboardTab(VerticalScroll):
"""Single-screen dashboard packing system / storage / network /
services / disks into one vertically-flowing view."""
DEFAULT_CSS = """
DashboardTab {
padding: 0 1;
}
"""
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
# System (CPU + MEM compact, inline summary + braille graph) with Container(id="dashboard-grid"):
with Container(classes="panel"): with Vertical(id="cpu-panel", classes="panel"):
yield Static("SYSTEM", classes="panel-title") yield Static("CPU", classes="panel-title")
self.cpu_line = Static("") self.cpu_meter = MeterBar("CPU", color=theme.CPU_BAR)
yield self.cpu_line self.cpu_meter.add_class("meter-cpu")
self.cpu_graph = BrailleGraph(color="red", y_max=100.0) yield self.cpu_meter
self.cpu_graph.styles.height = 4 self.cpu_graph = BrailleGraph(color="red", y_max=100.0)
yield self.cpu_graph yield self.cpu_graph
self.mem_line = Static("") self.cpu_summary = Static("", id="cpu-summary")
yield self.mem_line yield self.cpu_summary
with Container(classes="panel"): with Vertical(id="storage-panel", 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 Container(classes="panel"): with Vertical(id="mem-panel", classes="panel"):
yield Static("NETWORK", classes="panel-title") yield Static("MEMORY", classes="panel-title")
self.net_summary = Static("") self.mem_meter = MeterBar("MEM", color=theme.MEM_BAR)
yield self.net_summary self.mem_meter.add_class("meter-mem")
with Horizontal(id="net-graphs"): yield self.mem_meter
self.net_rx_graph = BrailleGraph(color="magenta") self.mem_summary = Static("", id="mem-summary")
self.net_rx_graph.styles.height = 4 yield self.mem_summary
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"): with Vertical(id="net-panel", classes="panel"):
yield Static("SERVICES", classes="panel-title") yield Static("NETWORK", classes="panel-title")
self.services_body = Static("") self.net_summary = Static("")
yield self.services_body yield self.net_summary
with Horizontal():
with Container(classes="panel"): self.net_rx_graph = BrailleGraph(color="magenta")
yield Static("DISKS", classes="panel-title") self.net_tx_graph = BrailleGraph(color="blue")
self.disks_table = DataTable(zebra_stripes=False, show_cursor=False) yield self.net_rx_graph
self.disks_table.add_columns("DISK", "MODEL", "SIZE", "TEMP", "HEALTH", "REALLOC", "PENDING") yield self.net_tx_graph
yield self.disks_table
def update_system(self, s: SystemSnapshot) -> None: def update_system(self, s: SystemSnapshot) -> None:
bar = _inline_bar(s.cpu_percent, 30, theme.CPU_BAR) self.cpu_meter.update(s.cpu_percent)
temp = f" [grey70]temp[/] {s.temperature_c:.0f}°C" if s.temperature_c is not None else ""
self.cpu_line.update(
f"[bold]CPU[/] {s.cpu_percent:5.1f}% {bar} "
f"[grey70]load[/] {s.load_1:.2f}/{s.load_5:.2f}/{s.load_15:.2f} "
f"[grey70]{s.cpu_count}c[/] [grey70]up[/] {_fmt_uptime(s.uptime_seconds)}{temp}"
)
self.cpu_graph.push(s.cpu_percent) self.cpu_graph.push(s.cpu_percent)
temp = f" temp {s.temperature_c:.0f}°C" if s.temperature_c is not None else ""
mem_bar = _inline_bar(s.mem_percent, 30, theme.MEM_BAR) self.cpu_summary.update(
used_mib = s.mem_used_kb / 1024 f"load {s.load_1:.2f} {s.load_5:.2f} {s.load_15:.2f} "
total_mib = s.mem_total_kb / 1024 f"cores {s.cpu_count} up {_fmt_uptime(s.uptime_seconds)}{temp}"
cached_mib = s.mem_cached_kb / 1024 )
self.mem_line.update( self.mem_meter.update(s.mem_percent)
f"[bold]MEM[/] {s.mem_percent:5.1f}% {mem_bar} " used_mb = s.mem_used_kb / 1024
f"[grey70]used[/] {used_mib:6.0f} / {total_mib:6.0f} MiB " total_mb = s.mem_total_kb / 1024
f"[grey70]cached[/] {cached_mib:5.0f} MiB" 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"
) )
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 = _inline_bar(v.percent, 24, color) bar = f"[{color}]" + "█" * filled + "[/]" + "░" * (bar_w - filled)
lines.append( lines.append(
f" {v.mount:<14} {v.used:>7} / {v.size:>7} {bar} {v.percent:5.1f}%" f"{v.mount:<14} {v.used:>6}/{v.size:>6} {bar} {v.percent:5.1f}%"
) )
if not lines: if not lines:
lines.append(" (no volumes detected)") lines.append("(no volumes detected)")
if s.raids: for r in s.raids:
lines.append("") lines.append(f" RAID {r.name} {r.level} {r.state}")
for r in s.raids:
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:
primary = s.primary() primary = s.primary()
if primary is None: if primary is None:
self.net_summary.update(" (no interface)") self.net_summary.update("(no interface)")
return return
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)
# One line per interface so multi-NIC setups (LACP, OvS, Tailscale) self.net_summary.update(
# are visible. The primary is highlighted because the graph f"{primary.iface} "
# underneath tracks it. f"[{theme.NET_DOWN}]↓ {_fmt_bytes_per_sec(primary.rx_bps)}[/] "
max_name = max((len(s.iface) for s in s.samples), default=8) f"[{theme.NET_UP}]↑ {_fmt_bytes_per_sec(primary.tx_bps)}[/]"
max_ip = max((len(s.ip) for s in s.samples), default=0) )
lines = []
for sample in s.samples:
iface = sample.iface.ljust(max_name)
ip = (sample.ip or "—").ljust(max_ip if max_ip else 1)
tag = "[bold]●[/]" if sample is primary else " "
lines.append(
f" {tag} [bold]{iface}[/] [grey70]{ip}[/] "
f"[{theme.NET_DOWN}]↓ {_fmt_bytes_per_sec(sample.rx_bps)}[/] "
f"[{theme.NET_UP}]↑ {_fmt_bytes_per_sec(sample.tx_bps)}[/]"
)
self.net_summary.update("\n".join(lines))
SERVICES_LIMIT = 30
def update_services(self, services: Iterable[ServiceInfo]) -> None:
items = list(services)
if not items:
self.services_body.update(" (no services detected)")
return
total = len(items)
running = sum(1 for s in items if s.running)
# The collector already sorted interesting+running first, so the
# head of the list is the most useful.
shown = items[: self.SERVICES_LIMIT]
max_name = min(20, max((len(s.name) for s in shown), default=8))
cells = []
for s in shown:
icon = f"[{theme.OK}]✅[/]" if s.running else f"[{theme.STOPPED}]⛔[/]"
name = (s.name[:max_name]).ljust(max_name)
cells.append(f"{icon} {name}")
per_row = 3
rows = [f" [dim]({running}/{total} running, showing top {len(shown)})[/]"]
for i in range(0, len(cells), per_row):
rows.append(" " + " ".join(cells[i:i + per_row]))
self.services_body.update("\n".join(rows))
def update_disks(self, snap: DisksSnapshot) -> None:
self.disks_table.clear()
for d in snap.disks:
temp = f"{d.temperature_c}°C" if d.temperature_c is not None else "—"
health_color = (
theme.OK if d.smart_ok is True
else theme.CRITICAL if d.smart_ok is False
else theme.WARNING
)
health = f"[{health_color}]{d.health}[/]"
model = (d.model or "—")[:30]
self.disks_table.add_row(
d.device.removeprefix("/dev/"), model, d.size or "—", temp, health,
str(d.reallocated) if d.reallocated is not None else "—",
str(d.pending) if d.pending is not None else "—",
)

49
dsmview/ui/tabs/disks.py Normal file
View file

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

View file

@ -3,10 +3,10 @@ from __future__ import annotations
from collections import deque from collections import deque
from typing import Deque from typing import Deque
from rich.markup import escape
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.containers import Container from textual.binding import Binding
from textual.widgets import Input, RichLog, Static from textual.containers import Container, Horizontal
from textual.widgets import RichLog, Static
from dsmview.collectors.logs import LogLine, Severity from dsmview.collectors.logs import LogLine, Severity
from dsmview.ui import theme from dsmview.ui import theme
@ -16,66 +16,32 @@ _MAX_LINES = 2000
class LogsTab(Container): class LogsTab(Container):
"""Live log tail with severity and text filters. BINDINGS = [
Binding("a", "filter('ALL')", "ALL", show=False),
Severity filters (a / 1-4) are bound at the App level. Press ``/`` to Binding("1", "filter('ERROR')", "ERROR", show=False),
start a text search and ``escape`` to clear it. 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__()
self._buffer: Deque[LogLine] = deque(maxlen=_MAX_LINES) self._buffer: Deque[LogLine] = deque(maxlen=_MAX_LINES)
self._severity: str = "ALL" self._filter: str = "ALL"
self._search: str = ""
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with Container(classes="panel"): with Container(classes="panel"):
yield Static( yield Static("LOGS — [A]ll [1]Err [2]Warn [3]Sec [4]Info", classes="panel-title", id="logs-header")
"LOGS — [A]LL [1]ERROR [2]WARN [3]SECURITY [4]INFO [/]/ search ESC clear",
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.search_input = Input( self.log_view = RichLog(highlight=False, markup=True, wrap=False, max_lines=_MAX_LINES, id="logs-view")
placeholder="search logs...",
id="logs-search",
)
self.search_input.display = False
yield self.search_input
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 set_filter(self, name: str) -> None: def action_filter(self, name: str) -> None:
self._severity = name self._filter = name
self._update_status() self.status_bar.update(f"filter: {name}")
self._rerender() self._rerender()
def set_search(self, text: str) -> None:
self._search = text.strip().lower()
self._update_status()
self._rerender()
def show_search(self) -> None:
self.search_input.display = True
self.search_input.focus()
def hide_search(self) -> None:
self.search_input.display = False
self.search_input.value = ""
self.set_search("")
self.log_view.focus()
def on_input_changed(self, event: Input.Changed) -> None:
if event.input.id == "logs-search":
self.set_search(event.value)
def push_initial(self, lines: list[LogLine]) -> None: def push_initial(self, lines: list[LogLine]) -> None:
self._buffer.extend(lines) self._buffer.extend(lines)
self._rerender() self._rerender()
@ -86,12 +52,9 @@ class LogsTab(Container):
self.log_view.write(self._format(line)) self.log_view.write(self._format(line))
def _matches(self, line: LogLine) -> bool: def _matches(self, line: LogLine) -> bool:
if self._severity != "ALL" and line.severity.value != self._severity: if self._filter == "ALL":
return False return True
if self._search: return line.severity.value == self._filter
haystack = (line.raw + " " + line.source).lower()
return self._search in haystack
return True
def _format(self, line: LogLine) -> str: def _format(self, line: LogLine) -> str:
color_for = { color_for = {
@ -102,17 +65,8 @@ class LogsTab(Container):
Severity.OTHER: theme.BORDER_TITLE, Severity.OTHER: theme.BORDER_TITLE,
} }
color = color_for[line.severity] color = color_for[line.severity]
ts = escape(line.timestamp) if line.timestamp else "" src = f"[dim]{line.source:<8}[/]"
ts_part = f"[dim]{ts:<28}[/] " if ts else "" return f"{src} [{color}]{line.raw}[/]"
src = f"[dim]{line.source:<12}[/]"
body = escape(line.message or line.raw)
return f"{ts_part}{src} [{color}]{body}[/]"
def _update_status(self) -> None:
parts = [f"severity: {self._severity}"]
if self._search:
parts.append(f"search: \"{self._search}\"")
self.status_bar.update(" ".join(parts))
def _rerender(self) -> None: def _rerender(self) -> None:
self.log_view.clear() self.log_view.clear()

View file

@ -1,219 +1,45 @@
from __future__ import annotations from __future__ import annotations
from typing import Callable, Iterable
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.containers import Container, Horizontal, VerticalScroll from textual.binding import Binding
from textual.screen import ModalScreen from textual.containers import Container
from textual.widgets import Button, DataTable, Static from textual.widgets import Static
from dsmview.collectors.services import ServiceInfo from dsmview.collectors.services import ServiceInfo
from dsmview.ssh.executor import Executor from dsmview.ui.widgets import ServiceList
from dsmview.ui import theme
class ConfirmActionScreen(ModalScreen[bool]): class ServicesTab(Container):
"""Tiny modal asking the user to confirm a destructive service action.""" 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),
]
DEFAULT_CSS = """ def __init__(self, on_action: Callable[[str, str], None]) -> None:
ConfirmActionScreen {
align: center middle;
}
ConfirmActionScreen > Container {
width: 60;
height: auto;
border: round $border;
background: $surface;
padding: 1 2;
}
ConfirmActionScreen #confirm-title {
text-style: bold;
height: auto;
margin-bottom: 1;
}
ConfirmActionScreen #confirm-buttons {
height: auto;
align: center middle;
}
"""
def __init__(self, action: str, service: str) -> None:
super().__init__() super().__init__()
self.action = action self._on_action = on_action
self.service = service
def compose(self) -> ComposeResult:
with Container():
yield Static(
f"Confirm {self.action} of [bold]{self.service}[/]?",
id="confirm-title",
)
with Horizontal(id="confirm-buttons"):
yield Button("Yes", id="confirm-yes", variant="error")
yield Button("No", id="confirm-no", variant="primary")
def on_button_pressed(self, event: Button.Pressed) -> None:
self.dismiss(event.button.id == "confirm-yes")
class ServicesTab(VerticalScroll):
"""Interactive service list with visible start/stop/restart controls."""
DEFAULT_CSS = """
ServicesTab {
padding: 0 1;
}
ServicesTab #services-table {
height: 1fr;
min-height: 10;
}
ServicesTab #services-buttons {
height: auto;
margin-top: 1;
}
"""
def __init__(self, executor: Executor) -> None:
super().__init__()
self.executor = executor
self._services: list[ServiceInfo] = []
self._last_cursor_row: int = 0
self._last_selected_unit: str = ""
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with Container(classes="panel"): with Container(classes="panel"):
yield Static( yield Static(
"SERVICES — ↑/↓ select, then Start / Stop / Restart", "SERVICES — [enter] detail [r] restart [s] stop [S] start",
classes="panel-title", classes="panel-title",
id="services-header",
) )
self.status_bar = Static("loading...", id="services-status") self.table = ServiceList()
yield self.status_bar
self.table = DataTable(
zebra_stripes=True,
show_cursor=True,
cursor_type="row",
id="services-table",
)
self.table.add_columns("SERVICE", "STATUS", "DESCRIPTION")
yield self.table yield self.table
with Horizontal(id="services-buttons"):
yield Button("Start", id="btn-start", variant="success")
yield Button("Stop", id="btn-stop", variant="error")
yield Button("Restart", id="btn-restart", variant="warning")
def update(self, services: list[ServiceInfo]) -> None: def update_services(self, services: Iterable[ServiceInfo]) -> None:
# Remember what was selected so we can restore the cursor after the self.table.update_services(services)
# periodic refresh re-renders the table.
if self.table.row_count:
coord = self.table.cursor_coordinate
self._last_cursor_row = coord.row
if 0 <= coord.row < len(self._services):
self._last_selected_unit = self._services[coord.row].unit
else:
self._last_selected_unit = ""
self._services = services def action_service_action(self, action: str) -> None:
self.table.clear() row = self.table.cursor_row
running = sum(1 for s in services if s.running) if row is None or row < 0 or row >= self.table.row_count:
self.status_bar.update(
f"[dim]{running}/{len(services)} running[/]"
)
for s in services:
status = (
f"[{theme.OK}]running[/]" if s.running
else f"[{theme.STOPPED}]stopped[/]"
)
self.table.add_row(s.name, status, s.description)
# Restore cursor: prefer the previously selected unit, otherwise the
# previously selected row index.
new_row = -1
if self._last_selected_unit:
for i, s in enumerate(services):
if s.unit == self._last_selected_unit:
new_row = i
break
if new_row < 0 and 0 <= self._last_cursor_row < len(services):
new_row = self._last_cursor_row
if new_row >= 0:
self.table.move_cursor(row=new_row)
self._update_action_status()
def focus_table(self) -> None:
self.table.focus()
def action_start_service(self) -> None:
self._run_action("start")
def action_stop_service(self) -> None:
self._run_action("stop")
def action_restart_service(self) -> None:
self._run_action("restart")
def _run_action(self, action: str) -> None:
info = self._selected_service()
if info is None:
self.app.notify("no service selected", severity="warning", timeout=3)
return return
row_key = self.table.coordinate_to_cell_key((row, 0)).row_key
def on_confirm(confirmed: bool) -> None: name = row_key.value if row_key else None
if not confirmed: if not name:
return return
self._execute_action(action, info) self._on_action(action, name)
self.app.push_screen(ConfirmActionScreen(action, info.name), on_confirm)
def _selected_service(self) -> ServiceInfo | None:
coord = self.table.cursor_coordinate
if coord.row < 0 or coord.row >= len(self._services):
return None
return self._services[coord.row]
def _execute_action(self, action: str, info: ServiceInfo) -> None:
self.app.notify(
f"{action} {info.name}...",
severity="information",
timeout=3,
)
async def run() -> None:
unit = info.unit if info.unit else f"{info.name}.service"
cmd = f"systemctl {action} {unit} 2>&1"
result = await self.executor.run(cmd, timeout=30.0)
if result.ok:
self.app.notify(
f"{action} {info.name} OK",
severity="information",
timeout=3,
)
else:
self.app.notify(
f"{action} {info.name} failed: {result.stderr or result.stdout}",
severity="error",
timeout=6,
)
self.app.run_worker(run(), group="service-action", exclusive=False)
def _update_action_status(self) -> None:
info = self._selected_service()
name = info.name if info else "—"
running = sum(1 for s in self._services if s.running)
self.status_bar.update(
f"[dim]{running}/{len(self._services)} running[/] │ "
f"selected: [bold]{name}[/] │ Start Stop Restart"
)
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
if event.data_table.id == "services-table":
self._update_action_status()
def on_button_pressed(self, event: Button.Pressed) -> None:
mapping = {
"btn-start": "start",
"btn-stop": "stop",
"btn-restart": "restart",
}
action = mapping.get(event.button.id or "")
if action:
self._run_action(action)

View file

@ -27,6 +27,11 @@ 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};
@ -63,24 +68,52 @@ Tab.-active {{
background: {BG}; background: {BG};
color: {BORDER_TITLE}; color: {BORDER_TITLE};
padding: 0 1; padding: 0 1;
margin-bottom: 1;
height: auto;
}} }}
.panel > Static.panel-title {{ .panel > .panel-title {{
color: {BORDER_TITLE}; color: {BORDER_TITLE};
text-style: bold; text-style: bold;
height: 1;
}} }}
#net-graphs {{ .meter-cpu Bar > .bar--bar {{
height: 5; 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};
}} }}
DataTable {{ DataTable {{
background: {BG}; background: {BG};
color: {BORDER_TITLE}; color: {BORDER_TITLE};
height: auto;
}} }}
DataTable > .datatable--header {{ DataTable > .datatable--header {{
@ -88,4 +121,43 @@ DataTable > .datatable--header {{
color: {BORDER_TITLE}; color: {BORDER_TITLE};
text-style: bold; text-style: bold;
}} }}
DataTable > .datatable--cursor {{
background: {BORDER};
color: {BG};
}}
#dashboard-grid {{
layout: grid;
grid-size: 2 3;
grid-columns: 1fr 1fr;
grid-rows: 1fr 1fr auto;
grid-gutter: 0;
}}
#cpu-panel, #mem-panel, #storage-panel, #net-panel, #services-row, #logs-row {{
height: 100%;
width: 100%;
}}
#services-row, #logs-row {{
column-span: 2;
}}
ConfirmDialog {{
align: center middle;
}}
ConfirmDialog > #dialog {{
width: 60;
height: 9;
background: {BG};
border: round {WARNING};
padding: 1 2;
}}
ConfirmDialog #dialog-title {{
color: {WARNING};
text-style: bold;
}}
""" """

View file

@ -1,4 +1,5 @@
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"] __all__ = ["BrailleGraph", "MeterBar", "ServiceList"]

View file

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

View file

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