feat: initial dsmview - TUI SSH monitor for Synology DSM 7.x

- Python 3.12+ / Textual TUI with btop-style theme and 4 tabs
  (Dashboard, Logs, Services, Disks)
- Paramiko SSH layer with ssh_config support, key fallback,
  keepalive and reconnect; async executor over a thread pool
- Read-only collectors for system, storage, disks, network,
  logs and services; confirm dialog gates service restart/stop/start
- Click CLI: dsmview ssh [-p PORT] [-i KEY] TARGET
- Homebrew formula using virtualenv_install_with_resources
- GitHub Actions release workflow: builds source tarball,
  regenerates Python resource blocks via homebrew-pypi-poet,
  pushes updated formula to janvete/homebrew-dsmview tap

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jan Vetesnik 2026-06-13 16:12:58 +02:00
commit af63fc7b15
31 changed files with 2149 additions and 0 deletions

128
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,128 @@
name: Release
on:
push:
tags:
- 'v[0-9]*'
permissions:
contents: write
jobs:
release:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install build tooling
run: |
python -m pip install --upgrade pip
pip install build homebrew-pypi-poet
- name: Compute release metadata
id: meta
run: |
VERSION="${GITHUB_REF_NAME#v}"
TAG="${GITHUB_REF_NAME}"
TARBALL_URL="https://github.com/${{ github.repository }}/archive/refs/tags/${TAG}.tar.gz"
curl -fsSL -o /tmp/source.tar.gz "${TARBALL_URL}"
SOURCE_SHA=$(shasum -a 256 /tmp/source.tar.gz | awk '{print $1}')
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "tarball_url=${TARBALL_URL}" >> "$GITHUB_OUTPUT"
echo "source_sha=${SOURCE_SHA}" >> "$GITHUB_OUTPUT"
- name: Install dsmview into a venv (for poet)
run: |
python -m venv .poet-venv
. .poet-venv/bin/activate
pip install --upgrade pip
pip install .
- name: Generate Python resource blocks
id: poet
run: |
. .poet-venv/bin/activate
poet dsmview > resources.rb
echo "--- resources.rb ---"
cat resources.rb
- name: Render Formula/dsmview.rb
run: |
python <<'PY'
import re, pathlib
version = "${{ steps.meta.outputs.version }}"
tag = "${{ steps.meta.outputs.tag }}"
url = "${{ steps.meta.outputs.tarball_url }}"
src_sha = "${{ steps.meta.outputs.source_sha }}"
resources = pathlib.Path("resources.rb").read_text().rstrip() + "\n"
formula = f'''class Dsmview < Formula
include Language::Python::Virtualenv
desc "TUI SSH monitor for Synology DSM 7.x"
homepage "https://github.com/janvete/dsmview"
url "{url}"
sha256 "{src_sha}"
license "MIT"
head "https://github.com/janvete/dsmview.git", branch: "main"
depends_on "python@3.12"
depends_on "rust" => :build
{resources}
def install
virtualenv_install_with_resources
end
test do
assert_match "dsmview", shell_output("#{{bin}}/dsmview --help")
end
end
'''
pathlib.Path("Formula/dsmview.rb").write_text(formula)
PY
cat Formula/dsmview.rb
- name: Create GitHub Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "${{ steps.meta.outputs.tag }}" \
--title "${{ steps.meta.outputs.tag }}" \
--generate-notes
- name: Push formula to tap repository
env:
TAP_TOKEN: ${{ secrets.TAP_TOKEN }}
run: |
git clone "https://x-access-token:${TAP_TOKEN}@github.com/janvete/homebrew-dsmview.git" tap
cp Formula/dsmview.rb tap/Formula/dsmview.rb
cd tap
git config user.email "github-actions[bot]@users.noreply.github.com"
git config user.name "github-actions[bot]"
git add Formula/dsmview.rb
git commit -m "chore: bump dsmview to ${{ steps.meta.outputs.tag }}"
git push
- name: Commit updated formula back to source repo
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git config user.email "github-actions[bot]@users.noreply.github.com"
git config user.name "github-actions[bot]"
git fetch origin main
git checkout main
cp Formula/dsmview.rb /tmp/dsmview.rb
git pull --rebase origin main || true
cp /tmp/dsmview.rb Formula/dsmview.rb
git add Formula/dsmview.rb
if ! git diff --cached --quiet; then
git commit -m "chore: refresh formula for ${{ steps.meta.outputs.tag }}"
git push origin main
fi

14
.gitignore vendored Normal file
View file

@ -0,0 +1,14 @@
__pycache__/
*.py[cod]
*.egg-info/
.eggs/
build/
dist/
.venv/
venv/
.env
.pytest_cache/
.mypy_cache/
.ruff_cache/
.DS_Store
*.log

50
Formula/dsmview.rb Normal file
View file

@ -0,0 +1,50 @@
class Dsmview < Formula
include Language::Python::Virtualenv
desc "TUI SSH monitor for Synology DSM 7.x"
homepage "https://github.com/janvete/dsmview"
url "https://github.com/janvete/dsmview/archive/refs/tags/v0.1.0.tar.gz"
sha256 "0000000000000000000000000000000000000000000000000000000000000000"
license "MIT"
head "https://github.com/janvete/dsmview.git", branch: "main"
depends_on "python@3.12"
depends_on "rust" => :build # cryptography wheels build dep on some platforms
# PYTHON_RESOURCES_BEGIN
# The release workflow regenerates the resource blocks below using
# homebrew-pypi-poet whenever a new tag is pushed.
resource "textual" do
url "https://files.pythonhosted.org/packages/source/t/textual/textual-0.86.3.tar.gz"
sha256 "0000000000000000000000000000000000000000000000000000000000000000"
end
resource "paramiko" do
url "https://files.pythonhosted.org/packages/source/p/paramiko/paramiko-3.5.0.tar.gz"
sha256 "0000000000000000000000000000000000000000000000000000000000000000"
end
resource "click" do
url "https://files.pythonhosted.org/packages/source/c/click/click-8.1.7.tar.gz"
sha256 "0000000000000000000000000000000000000000000000000000000000000000"
end
resource "plotext" do
url "https://files.pythonhosted.org/packages/source/p/plotext/plotext-5.3.2.tar.gz"
sha256 "0000000000000000000000000000000000000000000000000000000000000000"
end
resource "textual-plotext" do
url "https://files.pythonhosted.org/packages/source/t/textual-plotext/textual_plotext-0.2.1.tar.gz"
sha256 "0000000000000000000000000000000000000000000000000000000000000000"
end
# PYTHON_RESOURCES_END
def install
virtualenv_install_with_resources
end
test do
assert_match "dsmview", shell_output("#{bin}/dsmview --help")
end
end

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Jan Vetesnik
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

52
README.md Normal file
View file

@ -0,0 +1,52 @@
# dsmview
TUI SSH monitor for Synology DSM 7.x — read-only dashboard inspired by `btop`.
```
brew tap janvete/dsmview
brew install dsmview
```
## Usage
```
dsmview ssh -p334 root@192.168.1.100
dsmview ssh root@192.168.1.100 # default port 22
dsmview ssh nas-home # alias from ~/.ssh/config
dsmview ssh -i ~/.ssh/id_nas root@IP
```
Reads `~/.ssh/config` (HostName, Port, IdentityFile, ProxyJump). Tries
`id_ed25519`, `id_ecdsa`, `id_rsa`, then the SSH agent. Falls back to a
password prompt only when no key works.
## Read-only philosophy
The application only reads data from the NAS. No agent is installed, no
configuration is touched. The single exception is start/stop/restart of a
service — and that requires explicit confirmation in the UI.
## Tabs
- **F1 Dashboard** — CPU history graph, memory, storage pools, network
throughput, load average, temperatures.
- **F2 Logs** — live tail of `/var/log/messages` and `/var/log/auth.log`
with severity filters (ALL / ERROR / WARN / SECURITY / INFO).
- **F3 Services** — `synoservicectl --list` table with up/down status and
restart/stop/start actions.
- **F4 Disks** — per-disk model/size/temp/health from `smartctl` plus RAID
status from `/proc/mdstat`.
## Keys
```
F1..F4 switch tab
r refresh now
q quit
```
## Requirements
- Python 3.12+
- Synology DSM 7.x with SSH enabled and a user that can run system commands
(typically `root` or a user in `administrators`).

1
dsmview/__init__.py Normal file
View file

@ -0,0 +1 @@
__version__ = "0.1.0"

57
dsmview/cli.py Normal file
View file

@ -0,0 +1,57 @@
from __future__ import annotations
import sys
from typing import Optional
import click
from dsmview import __version__
from dsmview.ssh.connection import NasConnection, parse_target
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
@click.version_option(__version__, prog_name="dsmview")
def main() -> None:
"""TUI monitor for Synology DSM 7.x over SSH (read-only)."""
@main.command()
@click.argument("target", required=True)
@click.option("-p", "--port", type=int, default=None, help="SSH port (default 22 or from ssh_config)")
@click.option("-i", "--identity", "identity_file", type=str, default=None, help="Explicit SSH private key file")
def ssh(target: str, port: Optional[int], identity_file: Optional[str]) -> None:
"""Connect to a NAS and launch the TUI.
Examples:
dsmview ssh -p334 root@192.168.1.100
dsmview ssh root@192.168.1.100
dsmview ssh nas-home # alias from ~/.ssh/config
dsmview ssh -i ~/.ssh/id_nas root@IP
"""
resolved = parse_target(target, explicit_port=port, explicit_identity=identity_file)
conn = NasConnection(target=resolved)
try:
click.echo(f"Connecting to {resolved.label}...", err=True)
conn.connect()
except PermissionError as e:
click.echo(f"error: {e}", err=True)
sys.exit(2)
except ConnectionError as e:
click.echo(f"error: {e}", err=True)
sys.exit(2)
except KeyboardInterrupt:
click.echo("aborted", err=True)
sys.exit(130)
from dsmview.ui import DsmviewApp
app = DsmviewApp(connection=conn)
try:
app.run()
finally:
conn.close()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,17 @@
from dsmview.collectors.base import Collector
from dsmview.collectors.disks import DisksCollector, DiskInfo
from dsmview.collectors.logs import LogCollector, LogLine
from dsmview.collectors.network import NetworkCollector, NetSample
from dsmview.collectors.services import ServicesCollector, ServiceInfo
from dsmview.collectors.storage import StorageCollector, VolumeInfo, RaidInfo
from dsmview.collectors.system import SystemCollector, SystemSnapshot
__all__ = [
"Collector",
"SystemCollector", "SystemSnapshot",
"StorageCollector", "VolumeInfo", "RaidInfo",
"DisksCollector", "DiskInfo",
"NetworkCollector", "NetSample",
"LogCollector", "LogLine",
"ServicesCollector", "ServiceInfo",
]

View file

@ -0,0 +1,24 @@
from __future__ import annotations
from typing import Generic, TypeVar
from dsmview.ssh.executor import Executor
T = TypeVar("T")
class Collector(Generic[T]):
"""Base class for periodic data collectors.
Each collector runs a small set of read-only commands and parses the
output into a typed snapshot. A missing/failing command should yield a
graceful partial result, not crash the UI.
"""
name: str = "collector"
def __init__(self, executor: Executor) -> None:
self.executor = executor
async def collect(self) -> T:
raise NotImplementedError

View file

@ -0,0 +1,94 @@
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import List, Optional
from dsmview.collectors.base import Collector
_SIZE_RE = re.compile(r"User Capacity:\s+[\d,]+\s+bytes\s+\[(?P<size>[^\]]+)\]")
_MODEL_RE = re.compile(r"Device Model:\s+(?P<model>.+)")
_TEMP_RE = re.compile(r"(?:Temperature_Celsius|Airflow_Temperature_Cel|Current Temperature)[^\d]+(\d+)")
_HEALTH_RE = re.compile(r"SMART overall-health self-assessment test result:\s+(\S+)")
_REALLOC_RE = re.compile(r"Reallocated_Sector_Ct\s+.*?\s(\d+)$", re.MULTILINE)
_PENDING_RE = re.compile(r"Current_Pending_Sector\s+.*?\s(\d+)$", re.MULTILINE)
_UNCORR_RE = re.compile(r"Offline_Uncorrectable\s+.*?\s(\d+)$", re.MULTILINE)
@dataclass(slots=True)
class DiskInfo:
device: str
model: str = ""
size: str = ""
temperature_c: Optional[int] = None
health: str = "N/A"
smart_ok: Optional[bool] = None
reallocated: Optional[int] = None
pending: Optional[int] = None
uncorrectable: Optional[int] = None
@dataclass(slots=True)
class DisksSnapshot:
disks: List[DiskInfo] = field(default_factory=list)
class DisksCollector(Collector[DisksSnapshot]):
name = "disks"
async def collect(self) -> DisksSnapshot:
listing = await self.executor.run("ls /dev/sd[a-z] 2>/dev/null")
devices: List[str] = []
for line in listing.stdout.split():
line = line.strip()
if line.startswith("/dev/sd") and len(line) == 8:
devices.append(line)
snap = DisksSnapshot()
if not devices:
return snap
smart_cmds = [f"smartctl -a {d} 2>/dev/null || true" for d in devices]
health_cmds = [f"smartctl -H {d} 2>/dev/null || true" for d in devices]
smart_results = await self.executor.gather(smart_cmds, timeout=15.0)
health_results = await self.executor.gather(health_cmds, timeout=10.0)
for device, smart, health in zip(devices, smart_results, health_results):
info = DiskInfo(device=device)
self._parse_smart(smart.stdout, info)
self._parse_health(health.stdout, info)
snap.disks.append(info)
return snap
@staticmethod
def _parse_smart(text: str, info: DiskInfo) -> None:
if not text:
return
m = _MODEL_RE.search(text)
if m:
info.model = m.group("model").strip()
m = _SIZE_RE.search(text)
if m:
info.size = m.group("size").strip()
m = _TEMP_RE.search(text)
if m:
try:
info.temperature_c = int(m.group(1))
except ValueError:
pass
m = _REALLOC_RE.search(text)
if m:
info.reallocated = int(m.group(1))
m = _PENDING_RE.search(text)
if m:
info.pending = int(m.group(1))
m = _UNCORR_RE.search(text)
if m:
info.uncorrectable = int(m.group(1))
@staticmethod
def _parse_health(text: str, info: DiskInfo) -> None:
m = _HEALTH_RE.search(text)
if m:
info.health = m.group(1).strip()
info.smart_ok = info.health.upper() in ("PASSED", "OK")

View file

@ -0,0 +1,69 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from enum import Enum
from typing import AsyncIterator, List, Optional
from dsmview.collectors.base import Collector
class Severity(str, Enum):
ERROR = "ERROR"
WARN = "WARN"
SECURITY = "SECURITY"
INFO = "INFO"
OTHER = "OTHER"
_ERROR_RE = re.compile(r"\b(error|fail(?:ed|ure)?|critical|EXT4-fs error|I/O error|RAID degraded|panic|oom)\b", re.IGNORECASE)
_WARN_RE = re.compile(r"\b(warn(?:ing)?|temperature|fan speed|link down|throttle)\b", re.IGNORECASE)
_SECURITY_RE = re.compile(r"\b(Failed password|Invalid user|authentication failure|sudo|session opened|session closed)\b")
_INFO_RE = re.compile(r"\b(completed|started|stopped|success|updated|finished)\b", re.IGNORECASE)
@dataclass(slots=True)
class LogLine:
raw: str
source: str
severity: Severity = Severity.OTHER
@classmethod
def classify(cls, raw: str, source: str) -> "LogLine":
# Security patterns are more specific (e.g. "Failed password" vs bare
# "fail") and must win over ERROR when both match.
sev = Severity.OTHER
if _SECURITY_RE.search(raw):
sev = Severity.SECURITY
elif _ERROR_RE.search(raw):
sev = Severity.ERROR
elif _WARN_RE.search(raw):
sev = Severity.WARN
elif _INFO_RE.search(raw):
sev = Severity.INFO
return cls(raw=raw, source=source, severity=sev)
class LogCollector(Collector[List[LogLine]]):
name = "logs"
SOURCES = (
("/var/log/messages", "messages"),
("/var/log/auth.log", "auth"),
)
async def collect(self) -> List[LogLine]:
cmds = [f"tail -n 200 {path} 2>/dev/null || true" for path, _ in self.SOURCES]
results = await self.executor.gather(cmds, timeout=10.0)
lines: List[LogLine] = []
for (_, source), res in zip(self.SOURCES, results):
for raw in res.stdout.splitlines():
if raw.strip():
lines.append(LogLine.classify(raw, source))
return lines
async def tail(self, source_index: int = 0) -> AsyncIterator[LogLine]:
path, source = self.SOURCES[source_index]
async for raw in self.executor.tail(path):
if raw.strip():
yield LogLine.classify(raw, source)

View file

@ -0,0 +1,76 @@
from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import Dict, List, Optional
from dsmview.collectors.base import Collector
@dataclass(slots=True)
class IfaceCounters:
rx_bytes: int = 0
tx_bytes: int = 0
timestamp: float = 0.0
@dataclass(slots=True)
class NetSample:
iface: str
rx_bps: float = 0.0
tx_bps: float = 0.0
rx_total: int = 0
tx_total: int = 0
@dataclass(slots=True)
class NetworkSnapshot:
samples: List[NetSample] = field(default_factory=list)
def primary(self) -> Optional[NetSample]:
for s in self.samples:
if s.iface in ("eth0", "ovs_eth0", "bond0"):
return s
return self.samples[0] if self.samples else None
class NetworkCollector(Collector[NetworkSnapshot]):
name = "network"
def __init__(self, executor) -> None:
super().__init__(executor)
self._prev: Dict[str, IfaceCounters] = {}
async def collect(self) -> NetworkSnapshot:
result = await self.executor.run("cat /proc/net/dev")
return self._parse(result.stdout)
def _parse(self, text: str) -> NetworkSnapshot:
snap = NetworkSnapshot()
now = time.monotonic()
for line in text.splitlines():
if ":" not in line:
continue
name, _, rest = line.partition(":")
iface = name.strip()
if iface in ("lo",) or iface.startswith("docker") or iface.startswith("veth"):
continue
fields = rest.split()
if len(fields) < 16:
continue
try:
rx = int(fields[0])
tx = int(fields[8])
except ValueError:
continue
current = IfaceCounters(rx_bytes=rx, tx_bytes=tx, timestamp=now)
prev = self._prev.get(iface)
sample = NetSample(iface=iface, rx_total=rx, tx_total=tx)
if prev is not None:
dt = current.timestamp - prev.timestamp
if dt > 0:
sample.rx_bps = max(0.0, (rx - prev.rx_bytes) / dt)
sample.tx_bps = max(0.0, (tx - prev.tx_bytes) / dt)
snap.samples.append(sample)
self._prev[iface] = current
return snap

View file

@ -0,0 +1,72 @@
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import List
from dsmview.collectors.base import Collector
_STATUS_RE = re.compile(r"\b(is\s+running|is\s+stopped|start/running|stop/waiting)\b", re.IGNORECASE)
@dataclass(slots=True)
class ServiceInfo:
name: str
running: bool = False
status_text: str = ""
@dataclass(slots=True)
class ServicesSnapshot:
services: List[ServiceInfo] = field(default_factory=list)
class ServicesCollector(Collector[ServicesSnapshot]):
name = "services"
DEFAULT_SERVICES = (
"smbd", "nmbd", "nginx", "sshd", "nfsd", "ftpd",
"avahi-daemon", "crond", "synoindexd",
"pkgctl-HyperBackup", "pkgctl-ActiveBackup", "pkgctl-CloudSync",
"pkgctl-MariaDB10", "pkgctl-ContainerManager",
)
async def collect(self) -> ServicesSnapshot:
listing = await self.executor.run("synoservicectl --list 2>/dev/null || true")
names = self._parse_list(listing.stdout)
if not names:
names = list(self.DEFAULT_SERVICES)
cmds = [f"synoservicectl --status {n} 2>&1 || true" for n in names]
results = await self.executor.gather(cmds, timeout=15.0)
snap = ServicesSnapshot()
for name, res in zip(names, results):
info = ServiceInfo(name=name, status_text=res.stdout.strip())
running = self._is_running(res.stdout)
info.running = running
snap.services.append(info)
return snap
@staticmethod
def _parse_list(text: str) -> List[str]:
out: List[str] = []
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
for token in line.split():
if token and token[0].isalpha() and "/" not in token:
out.append(token)
seen: dict[str, None] = {}
for n in out:
seen[n] = None
return list(seen.keys())
@staticmethod
def _is_running(text: str) -> bool:
low = text.lower()
if "is running" in low or "start/running" in low:
return True
if "is stopped" in low or "stop/waiting" in low:
return False
return "running" in low and "not" not in low

View file

@ -0,0 +1,88 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import List
from dsmview.collectors.base import Collector
@dataclass(slots=True)
class VolumeInfo:
device: str
mount: str
size: str
used: str
avail: str
percent: float
@dataclass(slots=True)
class RaidInfo:
name: str
level: str
state: str
devices: List[str] = field(default_factory=list)
@dataclass(slots=True)
class StorageSnapshot:
volumes: List[VolumeInfo] = field(default_factory=list)
raids: List[RaidInfo] = field(default_factory=list)
class StorageCollector(Collector[StorageSnapshot]):
name = "storage"
async def collect(self) -> StorageSnapshot:
df, mdstat = await self.executor.gather([
"df -hP",
"cat /proc/mdstat",
])
snap = StorageSnapshot()
snap.volumes = self._parse_df(df.stdout)
snap.raids = self._parse_mdstat(mdstat.stdout)
return snap
@staticmethod
def _parse_df(text: str) -> List[VolumeInfo]:
out: List[VolumeInfo] = []
for line in text.splitlines()[1:]:
parts = line.split()
if len(parts) < 6:
continue
device, size, used, avail, pct, mount = parts[:6]
if not mount.startswith("/volume") and not mount.startswith("/"):
continue
if device in ("tmpfs", "devtmpfs", "overlay", "none"):
continue
try:
p = float(pct.rstrip("%"))
except ValueError:
p = 0.0
out.append(VolumeInfo(
device=device, mount=mount,
size=size, used=used, avail=avail, percent=p,
))
return out
@staticmethod
def _parse_mdstat(text: str) -> List[RaidInfo]:
raids: List[RaidInfo] = []
current: RaidInfo | None = None
for line in text.splitlines():
line = line.rstrip()
if line.startswith("md"):
head = line.split()
if len(head) < 4:
continue
name = head[0]
state = head[2]
level = head[3] if len(head) > 3 else ""
devices = [d.split("[")[0] for d in head[4:]]
current = RaidInfo(name=name, level=level, state=state, devices=devices)
raids.append(current)
elif current is not None and ("[" in line and "_" in line or "U" in line):
stripped = line.strip()
if stripped.startswith("[") and "]" in stripped:
current.state = f"{current.state} {stripped}"
return raids

View file

@ -0,0 +1,179 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
from dsmview.collectors.base import Collector
@dataclass(slots=True)
class CpuTimes:
total: int = 0
idle: int = 0
def diff(self, prev: "CpuTimes") -> float:
dt = self.total - prev.total
di = self.idle - prev.idle
if dt <= 0:
return 0.0
return max(0.0, min(100.0, (1.0 - di / dt) * 100.0))
@dataclass(slots=True)
class SystemSnapshot:
hostname: str = ""
dsm_model: str = ""
dsm_version: str = ""
uptime_seconds: float = 0.0
load_1: float = 0.0
load_5: float = 0.0
load_15: float = 0.0
cpu_count: int = 0
cpu_model: str = ""
cpu_percent: float = 0.0
cpu_times: CpuTimes = field(default_factory=CpuTimes)
mem_total_kb: int = 0
mem_free_kb: int = 0
mem_available_kb: int = 0
mem_cached_kb: int = 0
mem_buffers_kb: int = 0
temperature_c: Optional[float] = None
@property
def mem_used_kb(self) -> int:
if self.mem_available_kb:
return max(0, self.mem_total_kb - self.mem_available_kb)
return max(0, self.mem_total_kb - self.mem_free_kb - self.mem_cached_kb - self.mem_buffers_kb)
@property
def mem_percent(self) -> float:
if not self.mem_total_kb:
return 0.0
return self.mem_used_kb / self.mem_total_kb * 100.0
class SystemCollector(Collector[SystemSnapshot]):
name = "system"
def __init__(self, executor) -> None:
super().__init__(executor)
self._prev_cpu: Optional[CpuTimes] = None
async def collect(self) -> SystemSnapshot:
cmds = [
"cat /proc/loadavg",
"cat /proc/meminfo",
"cat /proc/stat",
"cat /proc/cpuinfo",
"cat /proc/uptime",
"cat /proc/sys/kernel/hostname",
"cat /etc/synoinfo.conf 2>/dev/null | grep -E '^(unique|productversion|buildnumber|upnpmodelname)='",
"synotemperature 2>/dev/null || true",
]
results = await self.executor.gather(cmds)
snap = SystemSnapshot()
self._parse_loadavg(results[0].stdout, snap)
self._parse_meminfo(results[1].stdout, snap)
self._parse_stat(results[2].stdout, snap)
self._parse_cpuinfo(results[3].stdout, snap)
self._parse_uptime(results[4].stdout, snap)
snap.hostname = results[5].stdout.strip()
self._parse_synoinfo(results[6].stdout, snap)
self._parse_temp(results[7].stdout, snap)
return snap
@staticmethod
def _parse_loadavg(text: str, snap: SystemSnapshot) -> None:
parts = text.split()
if len(parts) >= 3:
try:
snap.load_1 = float(parts[0])
snap.load_5 = float(parts[1])
snap.load_15 = float(parts[2])
except ValueError:
pass
@staticmethod
def _parse_meminfo(text: str, snap: SystemSnapshot) -> None:
for line in text.splitlines():
key, _, rest = line.partition(":")
value = rest.strip().split()
if not value:
continue
try:
kb = int(value[0])
except ValueError:
continue
if key == "MemTotal":
snap.mem_total_kb = kb
elif key == "MemFree":
snap.mem_free_kb = kb
elif key == "MemAvailable":
snap.mem_available_kb = kb
elif key == "Cached":
snap.mem_cached_kb = kb
elif key == "Buffers":
snap.mem_buffers_kb = kb
def _parse_stat(self, text: str, snap: SystemSnapshot) -> None:
for line in text.splitlines():
if not line.startswith("cpu "):
continue
fields = line.split()
try:
values = [int(x) for x in fields[1:]]
except ValueError:
return
idle = values[3] + (values[4] if len(values) > 4 else 0)
total = sum(values)
now = CpuTimes(total=total, idle=idle)
if self._prev_cpu is not None:
snap.cpu_percent = now.diff(self._prev_cpu)
snap.cpu_times = now
self._prev_cpu = now
return
@staticmethod
def _parse_cpuinfo(text: str, snap: SystemSnapshot) -> None:
count = 0
model = ""
for line in text.splitlines():
if line.startswith("processor"):
count += 1
elif line.startswith("model name") and not model:
model = line.split(":", 1)[1].strip()
snap.cpu_count = count
snap.cpu_model = model
@staticmethod
def _parse_uptime(text: str, snap: SystemSnapshot) -> None:
parts = text.split()
if parts:
try:
snap.uptime_seconds = float(parts[0])
except ValueError:
pass
@staticmethod
def _parse_synoinfo(text: str, snap: SystemSnapshot) -> None:
for line in text.splitlines():
key, _, value = line.partition("=")
value = value.strip().strip('"')
if key == "upnpmodelname":
snap.dsm_model = value
elif key == "productversion":
snap.dsm_version = value
elif key == "buildnumber" and snap.dsm_version:
snap.dsm_version = f"{snap.dsm_version}-{value}"
@staticmethod
def _parse_temp(text: str, snap: SystemSnapshot) -> None:
text = text.strip()
if not text:
return
for token in text.replace("C", " ").split():
try:
snap.temperature_c = float(token)
return
except ValueError:
continue

4
dsmview/ssh/__init__.py Normal file
View file

@ -0,0 +1,4 @@
from dsmview.ssh.connection import NasConnection, SshTarget, parse_target
from dsmview.ssh.executor import CommandResult, Executor
__all__ = ["NasConnection", "SshTarget", "parse_target", "Executor", "CommandResult"]

233
dsmview/ssh/connection.py Normal file
View file

@ -0,0 +1,233 @@
from __future__ import annotations
import getpass
import os
import socket
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
import paramiko
from paramiko.config import SSHConfig
DEFAULT_KEY_NAMES = ("id_ed25519", "id_ecdsa", "id_rsa")
@dataclass
class SshTarget:
"""Resolved SSH connection parameters after merging CLI args and ssh_config."""
host: str
port: int = 22
user: str = "root"
identity_file: Optional[str] = None
proxy_jump: Optional[str] = None
original: str = ""
@property
def label(self) -> str:
return f"{self.user}@{self.host}:{self.port}"
def parse_target(
spec: str,
explicit_port: Optional[int] = None,
explicit_identity: Optional[str] = None,
ssh_config_path: Optional[Path] = None,
) -> SshTarget:
"""Parse user@host[:port] or an ssh_config alias into an SshTarget.
Explicit CLI flags (-p, -i) override values from ssh_config.
"""
original = spec
user: Optional[str] = None
host = spec
port: Optional[int] = None
if "@" in host:
user, host = host.split("@", 1)
if ":" in host:
host, port_s = host.rsplit(":", 1)
port = int(port_s)
if ssh_config_path is None:
ssh_config_path = Path.home() / ".ssh" / "config"
cfg_user: Optional[str] = None
cfg_port: Optional[int] = None
cfg_host: Optional[str] = None
cfg_identity: Optional[str] = None
cfg_proxy: Optional[str] = None
if ssh_config_path.exists():
cfg = SSHConfig()
with open(ssh_config_path) as fh:
cfg.parse(fh)
lookup = cfg.lookup(host)
cfg_host = lookup.get("hostname")
cfg_user = lookup.get("user")
if lookup.get("port"):
cfg_port = int(lookup["port"])
identities = lookup.get("identityfile")
if identities:
cfg_identity = identities[0] if isinstance(identities, list) else identities
cfg_proxy = lookup.get("proxyjump")
resolved_host = cfg_host or host
resolved_user = user or cfg_user or os.environ.get("USER") or "root"
resolved_port = explicit_port or port or cfg_port or 22
resolved_identity = explicit_identity or cfg_identity
return SshTarget(
host=resolved_host,
port=resolved_port,
user=resolved_user,
identity_file=resolved_identity,
proxy_jump=cfg_proxy,
original=original,
)
@dataclass
class NasConnection:
"""Live SSH connection to a Synology NAS.
Tries identity files in the canonical order, falls back to agent, then
prompts for a password. Keepalive is enabled so long-running tails do
not get killed by upstream firewalls.
"""
target: SshTarget
client: paramiko.SSHClient = field(init=False)
_proxy_sock: Optional[paramiko.Channel] = field(init=False, default=None)
_password: Optional[str] = field(init=False, default=None)
def __post_init__(self) -> None:
self.client = paramiko.SSHClient()
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
def connect(self, *, password: Optional[str] = None, timeout: float = 10.0) -> None:
sock = self._build_proxy_sock() if self.target.proxy_jump else None
identities = self._candidate_identities()
last_exc: Optional[Exception] = None
for identity in identities:
try:
self.client.connect(
hostname=self.target.host,
port=self.target.port,
username=self.target.user,
key_filename=str(identity) if identity else None,
allow_agent=True,
look_for_keys=identity is None,
timeout=timeout,
banner_timeout=timeout,
auth_timeout=timeout,
sock=sock,
)
self._enable_keepalive()
return
except paramiko.AuthenticationException as e:
last_exc = e
continue
except (paramiko.SSHException, socket.error) as e:
raise ConnectionError(f"SSH connection failed: {e}") from e
pw = password if password is not None else getpass.getpass(
f"Password for {self.target.label}: "
)
self._password = pw
try:
self.client.connect(
hostname=self.target.host,
port=self.target.port,
username=self.target.user,
password=pw,
allow_agent=False,
look_for_keys=False,
timeout=timeout,
banner_timeout=timeout,
auth_timeout=timeout,
sock=sock,
)
self._enable_keepalive()
except paramiko.AuthenticationException as e:
raise PermissionError(
f"Authentication failed for {self.target.label}"
) from e
def reconnect(self) -> None:
self.close()
self.client = paramiko.SSHClient()
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
self.connect(password=self._password)
def close(self) -> None:
try:
self.client.close()
except Exception:
pass
if self._proxy_sock is not None:
try:
self._proxy_sock.close()
except Exception:
pass
self._proxy_sock = None
def _candidate_identities(self) -> list[Optional[Path]]:
identities: list[Optional[Path]] = []
if self.target.identity_file:
identities.append(Path(os.path.expanduser(self.target.identity_file)))
return identities
ssh_dir = Path.home() / ".ssh"
for name in DEFAULT_KEY_NAMES:
p = ssh_dir / name
if p.exists():
identities.append(p)
identities.append(None)
return identities
def _enable_keepalive(self) -> None:
transport = self.client.get_transport()
if transport is not None:
transport.set_keepalive(15)
def _build_proxy_sock(self) -> Optional[paramiko.Channel]:
jump = self.target.proxy_jump
if jump is None:
return None
jump_user: Optional[str] = None
jump_host = jump
jump_port = 22
if "@" in jump_host:
jump_user, jump_host = jump_host.split("@", 1)
if ":" in jump_host:
jump_host, port_s = jump_host.rsplit(":", 1)
jump_port = int(port_s)
jump_client = paramiko.SSHClient()
jump_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
jump_client.connect(
hostname=jump_host,
port=jump_port,
username=jump_user or self.target.user,
allow_agent=True,
look_for_keys=True,
timeout=10,
)
transport = jump_client.get_transport()
assert transport is not None
channel = transport.open_channel(
"direct-tcpip",
(self.target.host, self.target.port),
("127.0.0.1", 0),
)
self._proxy_sock = channel
return channel
def __enter__(self) -> "NasConnection":
return self
def __exit__(self, exc_type, exc, tb) -> None:
self.close()

126
dsmview/ssh/executor.py Normal file
View file

@ -0,0 +1,126 @@
from __future__ import annotations
import asyncio
import threading
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from typing import AsyncIterator, Iterable, Optional
import paramiko
from dsmview.ssh.connection import NasConnection
@dataclass(slots=True)
class CommandResult:
cmd: str
stdout: str
stderr: str
exit_status: int
@property
def ok(self) -> bool:
return self.exit_status == 0
class Executor:
"""Runs read-only commands on the NAS.
Paramiko is not thread-safe per Channel, but the underlying Transport is.
Each `run()` opens its own exec channel so concurrent calls are fine when
submitted through the thread pool. Use `gather()` to fan out collectors.
"""
def __init__(self, connection: NasConnection, *, max_workers: int = 4) -> None:
self.connection = connection
self._pool = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="dsmview-exec")
self._lock = threading.Lock()
def close(self) -> None:
self._pool.shutdown(wait=False, cancel_futures=True)
def run_sync(self, cmd: str, *, timeout: float = 10.0) -> CommandResult:
with self._lock:
transport = self.connection.client.get_transport()
if transport is None or not transport.is_active():
self.connection.reconnect()
transport = self.connection.client.get_transport()
assert transport is not None
channel = transport.open_session(timeout=timeout)
try:
channel.settimeout(timeout)
channel.exec_command(cmd)
stdout = b""
stderr = b""
while True:
if channel.recv_ready():
stdout += channel.recv(65536)
if channel.recv_stderr_ready():
stderr += channel.recv_stderr(65536)
if channel.exit_status_ready():
while channel.recv_ready():
stdout += channel.recv(65536)
while channel.recv_stderr_ready():
stderr += channel.recv_stderr(65536)
break
status = channel.recv_exit_status()
return CommandResult(
cmd=cmd,
stdout=stdout.decode("utf-8", errors="replace"),
stderr=stderr.decode("utf-8", errors="replace"),
exit_status=status,
)
finally:
try:
channel.close()
except Exception:
pass
async def run(self, cmd: str, *, timeout: float = 10.0) -> CommandResult:
loop = asyncio.get_running_loop()
return await loop.run_in_executor(self._pool, lambda: self.run_sync(cmd, timeout=timeout))
async def gather(self, cmds: Iterable[str], *, timeout: float = 10.0) -> list[CommandResult]:
return await asyncio.gather(*(self.run(c, timeout=timeout) for c in cmds))
async def tail(self, path: str, *, lines: int = 200) -> AsyncIterator[str]:
"""Stream `tail -F path` line by line.
Yields lines as they arrive. Cancelling the consumer closes the channel.
"""
loop = asyncio.get_running_loop()
transport = self.connection.client.get_transport()
if transport is None or not transport.is_active():
await loop.run_in_executor(self._pool, self.connection.reconnect)
transport = self.connection.client.get_transport()
assert transport is not None
channel = transport.open_session()
channel.exec_command(f"tail -n {lines} -F {path}")
buffer = bytearray()
try:
while True:
chunk = await loop.run_in_executor(self._pool, self._recv_chunk, channel)
if chunk is None:
return
if chunk:
buffer.extend(chunk)
while b"\n" in buffer:
line, _, rest = buffer.partition(b"\n")
buffer = bytearray(rest)
yield line.decode("utf-8", errors="replace")
finally:
try:
channel.close()
except Exception:
pass
@staticmethod
def _recv_chunk(channel: paramiko.Channel) -> Optional[bytes]:
if channel.exit_status_ready() and not channel.recv_ready():
return None
try:
data = channel.recv(65536)
except Exception:
return None
return data

3
dsmview/ui/__init__.py Normal file
View file

@ -0,0 +1,3 @@
from dsmview.ui.app import DsmviewApp
__all__ = ["DsmviewApp"]

211
dsmview/ui/app.py Normal file
View file

@ -0,0 +1,211 @@
from __future__ import annotations
import asyncio
import time
from datetime import datetime
from typing import Optional
from textual import on, work
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Container
from textual.screen import ModalScreen
from textual.widgets import Footer, Static, TabbedContent, TabPane
from dsmview.collectors import (
DisksCollector,
LogCollector,
NetworkCollector,
ServicesCollector,
StorageCollector,
SystemCollector,
)
from dsmview.ssh.connection import NasConnection
from dsmview.ssh.executor import Executor
from dsmview.ui import theme
from dsmview.ui.tabs import DashboardTab, DisksTab, LogsTab, ServicesTab
REFRESH_INTERVAL = 10.0
class ConfirmDialog(ModalScreen[bool]):
"""Y/N confirmation modal for destructive actions."""
BINDINGS = [
Binding("y", "confirm(True)", "Yes", show=False),
Binding("n", "confirm(False)", "No", show=False),
Binding("escape", "confirm(False)", "Cancel", show=False),
]
def __init__(self, action: str, service: str, host: str) -> None:
super().__init__()
self.action = action
self.service = service
self.host = host
def compose(self) -> ComposeResult:
with Container(id="dialog"):
yield Static(f"⚠️ CONFIRM", id="dialog-title")
yield Static(f"{self.action.title()} service: [bold]{self.service}[/]")
yield Static(f"on: {self.host}")
yield Static("")
yield Static("[Y] Potvrdit [N] Zrušit")
def action_confirm(self, ok: bool) -> None:
self.dismiss(ok)
class DsmviewApp(App):
CSS = theme.CSS
TITLE = "dsmview"
BINDINGS = [
Binding("q", "quit", "Quit"),
Binding("r", "refresh_now", "Refresh"),
Binding("f1", "switch_tab('dashboard')", "Dash"),
Binding("f2", "switch_tab('logs')", "Logs"),
Binding("f3", "switch_tab('services')", "Services"),
Binding("f4", "switch_tab('disks')", "Disks"),
]
def __init__(self, connection: NasConnection) -> None:
super().__init__()
self.connection = connection
self.executor = Executor(connection, max_workers=6)
self.system = SystemCollector(self.executor)
self.storage = StorageCollector(self.executor)
self.disks = DisksCollector(self.executor)
self.network = NetworkCollector(self.executor)
self.logs = LogCollector(self.executor)
self.services = ServicesCollector(self.executor)
self._dsm_label: str = "—"
self._refresh_lock = asyncio.Lock()
self._tail_workers: list = []
def compose(self) -> ComposeResult:
self.topbar = Static("", id="topbar")
yield self.topbar
with TabbedContent(initial="dashboard"):
with TabPane("F1 Dashboard", id="dashboard"):
self.dashboard = DashboardTab()
yield self.dashboard
with TabPane("F2 Logs", id="logs"):
self.logs_tab = LogsTab()
yield self.logs_tab
with TabPane("F3 Services", id="services"):
self.services_tab = ServicesTab(on_action=self._handle_service_action)
yield self.services_tab
with TabPane("F4 Disks", id="disks"):
self.disks_tab = DisksTab()
yield self.disks_tab
yield Footer()
def on_mount(self) -> None:
self._update_topbar()
self.set_interval(REFRESH_INTERVAL, self.action_refresh_now)
self.set_interval(1.0, self._update_topbar)
self.action_refresh_now()
self._start_log_tails()
def on_unmount(self) -> None:
for w in self._tail_workers:
try:
w.cancel()
except Exception:
pass
self.executor.close()
self.connection.close()
def action_switch_tab(self, tab_id: str) -> None:
tabbed = self.query_one(TabbedContent)
tabbed.active = tab_id
def action_refresh_now(self) -> None:
self.run_worker(self._refresh_all(), exclusive=True, group="refresh")
async def _refresh_all(self) -> None:
async with self._refresh_lock:
try:
system_task = asyncio.create_task(self.system.collect())
storage_task = asyncio.create_task(self.storage.collect())
network_task = asyncio.create_task(self.network.collect())
services_task = asyncio.create_task(self.services.collect())
disks_task = asyncio.create_task(self.disks.collect())
system_snap = await system_task
storage_snap = await storage_task
net_snap = await network_task
services_snap = await services_task
disks_snap = await disks_task
except Exception as e:
self.notify(f"Refresh error: {e}", severity="error", timeout=5)
return
if not self._dsm_label or self._dsm_label == "—":
self._dsm_label = self._make_dsm_label(system_snap)
self._update_topbar()
self.dashboard.update_system(system_snap)
self.dashboard.update_storage(storage_snap)
self.dashboard.update_network(net_snap)
self.services_tab.update_services(services_snap.services)
self.disks_tab.update_disks(disks_snap)
self.disks_tab.update_raid(storage_snap)
def _start_log_tails(self) -> None:
self.run_worker(self._tail_logs_initial(), group="logs-init", exclusive=True)
for i, (path, source) in enumerate(self.logs.SOURCES):
w = self.run_worker(self._tail_logs(i), group=f"tail-{i}", exclusive=False)
self._tail_workers.append(w)
async def _tail_logs_initial(self) -> None:
try:
initial = await self.logs.collect()
self.logs_tab.push_initial(initial)
except Exception as e:
self.notify(f"Log fetch error: {e}", severity="warning", timeout=4)
async def _tail_logs(self, source_index: int) -> None:
try:
async for line in self.logs.tail(source_index):
self.logs_tab.push(line)
except Exception:
return
def _handle_service_action(self, action: str, name: str) -> None:
def on_result(ok: bool | None) -> None:
if ok:
self.run_worker(self._run_service_action(action, name), exclusive=False, group="svc")
self.push_screen(
ConfirmDialog(action, name, self.connection.target.label),
on_result,
)
async def _run_service_action(self, action: str, name: str) -> None:
flag = {"restart": "--restart", "stop": "--stop", "start": "--start"}[action]
result = await self.executor.run(f"synoservicectl {flag} {name}")
if result.ok:
self.notify(f"{action} {name}: OK")
else:
self.notify(f"{action} {name} failed: {result.stderr or result.stdout}", severity="error")
await self._refresh_all()
def _update_topbar(self) -> None:
now = datetime.now().strftime("%H:%M:%S")
target = self.connection.target.label
self.topbar.update(
f"[bold]dsmview[/] │ {target} │ {self._dsm_label} │ {now}"
)
@staticmethod
def _make_dsm_label(s) -> str:
parts = []
if s.dsm_model:
parts.append(s.dsm_model)
if s.dsm_version:
parts.append(f"DSM {s.dsm_version}")
if s.hostname:
parts.append(s.hostname)
return " ".join(parts) if parts else "—"

View file

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

View file

@ -0,0 +1,116 @@
from __future__ import annotations
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Static
from dsmview.collectors.network import NetworkSnapshot
from dsmview.collectors.storage import StorageSnapshot
from dsmview.collectors.system import SystemSnapshot
from dsmview.ui import theme
from dsmview.ui.widgets import BrailleGraph, MeterBar
def _fmt_bytes_per_sec(b: float) -> str:
units = ("B/s", "K/s", "M/s", "G/s")
i = 0
while b >= 1024 and i < len(units) - 1:
b /= 1024
i += 1
return f"{b:6.1f} {units[i]}"
def _fmt_uptime(s: float) -> str:
s = int(s)
days, s = divmod(s, 86400)
hours, s = divmod(s, 3600)
minutes, _ = divmod(s, 60)
if days:
return f"{days}d {hours:02d}h {minutes:02d}m"
return f"{hours:02d}h {minutes:02d}m"
class DashboardTab(Container):
def compose(self) -> ComposeResult:
with Container(id="dashboard-grid"):
with Vertical(id="cpu-panel", classes="panel"):
yield Static("CPU", classes="panel-title")
self.cpu_meter = MeterBar("CPU", color=theme.CPU_BAR)
self.cpu_meter.add_class("meter-cpu")
yield self.cpu_meter
self.cpu_graph = BrailleGraph(color="red", y_max=100.0)
yield self.cpu_graph
self.cpu_summary = Static("", id="cpu-summary")
yield self.cpu_summary
with Vertical(id="storage-panel", classes="panel"):
yield Static("STORAGE", classes="panel-title")
self.storage_body = Static("")
yield self.storage_body
with Vertical(id="mem-panel", classes="panel"):
yield Static("MEMORY", classes="panel-title")
self.mem_meter = MeterBar("MEM", color=theme.MEM_BAR)
self.mem_meter.add_class("meter-mem")
yield self.mem_meter
self.mem_summary = Static("", id="mem-summary")
yield self.mem_summary
with Vertical(id="net-panel", classes="panel"):
yield Static("NETWORK", classes="panel-title")
self.net_summary = Static("")
yield self.net_summary
with Horizontal():
self.net_rx_graph = BrailleGraph(color="magenta")
self.net_tx_graph = BrailleGraph(color="blue")
yield self.net_rx_graph
yield self.net_tx_graph
def update_system(self, s: SystemSnapshot) -> None:
self.cpu_meter.update(s.cpu_percent)
self.cpu_graph.push(s.cpu_percent)
temp = f" temp {s.temperature_c:.0f}°C" if s.temperature_c is not None else ""
self.cpu_summary.update(
f"load {s.load_1:.2f} {s.load_5:.2f} {s.load_15:.2f} "
f"cores {s.cpu_count} up {_fmt_uptime(s.uptime_seconds)}{temp}"
)
self.mem_meter.update(s.mem_percent)
used_mb = s.mem_used_kb / 1024
total_mb = s.mem_total_kb / 1024
cached_mb = s.mem_cached_kb / 1024
self.mem_summary.update(
f"used {used_mb:7.0f} MiB / total {total_mb:7.0f} MiB cached {cached_mb:6.0f} MiB"
)
def update_storage(self, s: StorageSnapshot) -> None:
lines = []
for v in s.volumes:
bar_w = 20
filled = int(round(bar_w * v.percent / 100.0))
color = (
theme.CRITICAL if v.percent >= 90
else theme.WARNING if v.percent >= 75
else theme.DISK_BAR
)
bar = f"[{color}]" + "█" * filled + "[/]" + "░" * (bar_w - filled)
lines.append(
f"{v.mount:<14} {v.used:>6}/{v.size:>6} {bar} {v.percent:5.1f}%"
)
if not lines:
lines.append("(no volumes detected)")
for r in s.raids:
lines.append(f" RAID {r.name} {r.level} {r.state}")
self.storage_body.update("\n".join(lines))
def update_network(self, s: NetworkSnapshot) -> None:
primary = s.primary()
if primary is None:
self.net_summary.update("(no interface)")
return
self.net_rx_graph.push(primary.rx_bps)
self.net_tx_graph.push(primary.tx_bps)
self.net_summary.update(
f"{primary.iface} "
f"[{theme.NET_DOWN}]↓ {_fmt_bytes_per_sec(primary.rx_bps)}[/] "
f"[{theme.NET_UP}]↑ {_fmt_bytes_per_sec(primary.tx_bps)}[/]"
)

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))

75
dsmview/ui/tabs/logs.py Normal file
View file

@ -0,0 +1,75 @@
from __future__ import annotations
from collections import deque
from typing import Deque
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Container, Horizontal
from textual.widgets import RichLog, Static
from dsmview.collectors.logs import LogLine, Severity
from dsmview.ui import theme
_MAX_LINES = 2000
class LogsTab(Container):
BINDINGS = [
Binding("a", "filter('ALL')", "ALL", show=False),
Binding("1", "filter('ERROR')", "ERROR", show=False),
Binding("2", "filter('WARN')", "WARN", show=False),
Binding("3", "filter('SECURITY')", "SECURITY", show=False),
Binding("4", "filter('INFO')", "INFO", show=False),
]
def __init__(self) -> None:
super().__init__()
self._buffer: Deque[LogLine] = deque(maxlen=_MAX_LINES)
self._filter: str = "ALL"
def compose(self) -> ComposeResult:
with Container(classes="panel"):
yield Static("LOGS — [A]ll [1]Err [2]Warn [3]Sec [4]Info", classes="panel-title", id="logs-header")
self.status_bar = Static("filter: ALL", id="logs-status")
yield self.status_bar
self.log_view = RichLog(highlight=False, markup=True, wrap=False, max_lines=_MAX_LINES, id="logs-view")
yield self.log_view
def action_filter(self, name: str) -> None:
self._filter = name
self.status_bar.update(f"filter: {name}")
self._rerender()
def push_initial(self, lines: list[LogLine]) -> None:
self._buffer.extend(lines)
self._rerender()
def push(self, line: LogLine) -> None:
self._buffer.append(line)
if self._matches(line):
self.log_view.write(self._format(line))
def _matches(self, line: LogLine) -> bool:
if self._filter == "ALL":
return True
return line.severity.value == self._filter
def _format(self, line: LogLine) -> str:
color_for = {
Severity.ERROR: theme.LOG_ERROR,
Severity.WARN: theme.LOG_WARN,
Severity.INFO: theme.LOG_INFO,
Severity.SECURITY: theme.LOG_SECURITY,
Severity.OTHER: theme.BORDER_TITLE,
}
color = color_for[line.severity]
src = f"[dim]{line.source:<8}[/]"
return f"{src} [{color}]{line.raw}[/]"
def _rerender(self) -> None:
self.log_view.clear()
for line in self._buffer:
if self._matches(line):
self.log_view.write(self._format(line))

View file

@ -0,0 +1,45 @@
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)

163
dsmview/ui/theme.py Normal file
View file

@ -0,0 +1,163 @@
"""btop-inspired color scheme for dsmview."""
BG = "#1a1a2e"
BORDER = "#454580"
BORDER_TITLE = "#a9b1d6"
CPU_BAR = "#e06c75"
MEM_BAR = "#98c379"
DISK_BAR = "#e5c07b"
NET_UP = "#61afef"
NET_DOWN = "#c678dd"
LOG_ERROR = "#e06c75"
LOG_WARN = "#e5c07b"
LOG_INFO = "#98c379"
LOG_SECURITY = "#c678dd"
OK = "#98c379"
WARNING = "#e5c07b"
CRITICAL = "#e06c75"
STOPPED = "#545862"
CSS = f"""
Screen {{
background: {BG};
color: {BORDER_TITLE};
}}
Header {{
background: {BG};
color: {BORDER_TITLE};
}}
Footer {{
background: {BG};
color: {BORDER_TITLE};
}}
TabbedContent {{
background: {BG};
}}
Tabs {{
background: {BG};
}}
Tab {{
background: {BG};
color: {BORDER_TITLE};
}}
Tab.-active {{
color: {OK};
text-style: bold;
}}
#topbar {{
height: 1;
background: {BG};
color: {BORDER_TITLE};
content-align: left middle;
padding: 0 1;
}}
.panel {{
border: round {BORDER};
background: {BG};
color: {BORDER_TITLE};
padding: 0 1;
}}
.panel > .panel-title {{
color: {BORDER_TITLE};
text-style: bold;
}}
.meter-cpu Bar > .bar--bar {{
color: {CPU_BAR};
}}
.meter-mem Bar > .bar--bar {{
color: {MEM_BAR};
}}
.meter-disk Bar > .bar--bar {{
color: {DISK_BAR};
}}
.severity-error {{
color: {LOG_ERROR};
}}
.severity-warn {{
color: {LOG_WARN};
}}
.severity-info {{
color: {LOG_INFO};
}}
.severity-security {{
color: {LOG_SECURITY};
}}
.svc-running {{
color: {OK};
}}
.svc-stopped {{
color: {STOPPED};
}}
DataTable {{
background: {BG};
color: {BORDER_TITLE};
}}
DataTable > .datatable--header {{
background: {BG};
color: {BORDER_TITLE};
text-style: bold;
}}
DataTable > .datatable--cursor {{
background: {BORDER};
color: {BG};
}}
#dashboard-grid {{
layout: grid;
grid-size: 2 3;
grid-columns: 1fr 1fr;
grid-rows: 1fr 1fr auto;
grid-gutter: 0;
}}
#cpu-panel, #mem-panel, #storage-panel, #net-panel, #services-row, #logs-row {{
height: 100%;
width: 100%;
}}
#services-row, #logs-row {{
column-span: 2;
}}
ConfirmDialog {{
align: center middle;
}}
ConfirmDialog > #dialog {{
width: 60;
height: 9;
background: {BG};
border: round {WARNING};
padding: 1 2;
}}
ConfirmDialog #dialog-title {{
color: {WARNING};
text-style: bold;
}}
"""

View file

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

View file

@ -0,0 +1,56 @@
from __future__ import annotations
from collections import deque
from typing import Deque
import plotext as plt
from rich.text import Text
from textual.widget import Widget
class BrailleGraph(Widget):
"""A compact braille-dot graph rendered with plotext.
Stores a rolling history and redraws on `push()`. Sized by Textual,
re-rendered to fit the current widget size.
"""
DEFAULT_CSS = """
BrailleGraph {
height: 100%;
width: 100%;
}
"""
def __init__(self, *, max_points: int = 120, color: str = "red", y_max: float | None = None) -> None:
super().__init__()
self.max_points: int = max_points
self.color: str = color
self.y_max: float | None = y_max
self._history: Deque[float] = deque(maxlen=max_points)
def push(self, value: float) -> None:
self._history.append(float(value))
self.refresh()
def reset(self) -> None:
self._history.clear()
self.refresh()
def render(self) -> Text:
if not self._history:
return Text("")
w = max(10, self.size.width - 2)
h = max(3, self.size.height - 1)
plt.clear_figure()
plt.plotsize(w, h)
plt.theme("clear")
data = list(self._history)
plt.plot(data, marker="braille", color=self.color)
plt.xaxes(False, False)
plt.yaxes(False, False)
plt.frame(False)
if self.y_max is not None:
plt.ylim(0, self.y_max)
rendered = plt.build()
return Text.from_ansi(rendered)

View file

@ -0,0 +1,45 @@
from __future__ import annotations
from rich.text import Text
from textual.reactive import reactive
from textual.widget import Widget
class MeterBar(Widget):
"""Single-row labelled bar (e.g. CPU 47% [█████░░░░░])."""
DEFAULT_CSS = """
MeterBar {
height: 1;
width: 100%;
}
"""
label: reactive[str] = reactive("")
value: reactive[float] = reactive(0.0)
suffix: reactive[str] = reactive("")
def __init__(self, label: str, *, color: str = "#e06c75") -> None:
super().__init__()
self.label = label
self.color = color
def update(self, value: float, suffix: str = "") -> None:
self.value = max(0.0, min(100.0, float(value)))
self.suffix = suffix
def render(self) -> Text:
width = max(10, self.size.width)
label_part = f"{self.label:<8}"
suffix_part = f" {self.value:5.1f}% {self.suffix}".rstrip()
bar_space = width - len(label_part) - len(suffix_part) - 2
bar_space = max(4, bar_space)
filled = int(round(bar_space * self.value / 100.0))
bar = "█" * filled + "░" * (bar_space - filled)
text = Text()
text.append(label_part, style="bold")
text.append("[")
text.append(bar, style=self.color)
text.append("]")
text.append(suffix_part)
return text

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)

46
pyproject.toml Normal file
View file

@ -0,0 +1,46 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "dsmview"
version = "0.1.0"
description = "TUI SSH monitor for Synology DSM 7.x"
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.12"
authors = [{ name = "Jan Vetesnik" }]
keywords = ["synology", "dsm", "nas", "tui", "ssh", "monitoring"]
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: Console :: Curses",
"Intended Audience :: System Administrators",
"License :: OSI Approved :: MIT License",
"Operating System :: MacOS",
"Operating System :: POSIX :: Linux",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.12",
"Topic :: System :: Monitoring",
"Topic :: System :: Systems Administration",
]
dependencies = [
"textual>=0.47.0",
"paramiko>=3.4.0",
"click>=8.1.0",
"plotext>=5.2.0",
"textual-plotext>=0.2.1",
]
[project.scripts]
dsmview = "dsmview.cli:main"
[project.urls]
Homepage = "https://github.com/janvete/dsmview"
Repository = "https://github.com/janvete/dsmview"
Issues = "https://github.com/janvete/dsmview/issues"
[tool.hatch.build.targets.wheel]
packages = ["dsmview"]
[tool.hatch.build.targets.sdist]
include = ["/dsmview", "/README.md", "/LICENSE"]