From af63fc7b153e32ad645b34da8cc9451e88caa946 Mon Sep 17 00:00:00 2001 From: Jan Vetesnik Date: Sat, 13 Jun 2026 16:12:58 +0200 Subject: [PATCH] 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) --- .github/workflows/release.yml | 128 +++++++++++++++ .gitignore | 14 ++ Formula/dsmview.rb | 50 ++++++ LICENSE | 21 +++ README.md | 52 +++++++ dsmview/__init__.py | 1 + dsmview/cli.py | 57 +++++++ dsmview/collectors/__init__.py | 17 ++ dsmview/collectors/base.py | 24 +++ dsmview/collectors/disks.py | 94 +++++++++++ dsmview/collectors/logs.py | 69 ++++++++ dsmview/collectors/network.py | 76 +++++++++ dsmview/collectors/services.py | 72 +++++++++ dsmview/collectors/storage.py | 88 +++++++++++ dsmview/collectors/system.py | 179 +++++++++++++++++++++ dsmview/ssh/__init__.py | 4 + dsmview/ssh/connection.py | 233 ++++++++++++++++++++++++++++ dsmview/ssh/executor.py | 126 +++++++++++++++ dsmview/ui/__init__.py | 3 + dsmview/ui/app.py | 211 +++++++++++++++++++++++++ dsmview/ui/tabs/__init__.py | 6 + dsmview/ui/tabs/dashboard.py | 116 ++++++++++++++ dsmview/ui/tabs/disks.py | 49 ++++++ dsmview/ui/tabs/logs.py | 75 +++++++++ dsmview/ui/tabs/services.py | 45 ++++++ dsmview/ui/theme.py | 163 +++++++++++++++++++ dsmview/ui/widgets/__init__.py | 5 + dsmview/ui/widgets/braille_graph.py | 56 +++++++ dsmview/ui/widgets/meter_bar.py | 45 ++++++ dsmview/ui/widgets/service_list.py | 24 +++ pyproject.toml | 46 ++++++ 31 files changed, 2149 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 Formula/dsmview.rb create mode 100644 LICENSE create mode 100644 README.md create mode 100644 dsmview/__init__.py create mode 100644 dsmview/cli.py create mode 100644 dsmview/collectors/__init__.py create mode 100644 dsmview/collectors/base.py create mode 100644 dsmview/collectors/disks.py create mode 100644 dsmview/collectors/logs.py create mode 100644 dsmview/collectors/network.py create mode 100644 dsmview/collectors/services.py create mode 100644 dsmview/collectors/storage.py create mode 100644 dsmview/collectors/system.py create mode 100644 dsmview/ssh/__init__.py create mode 100644 dsmview/ssh/connection.py create mode 100644 dsmview/ssh/executor.py create mode 100644 dsmview/ui/__init__.py create mode 100644 dsmview/ui/app.py create mode 100644 dsmview/ui/tabs/__init__.py create mode 100644 dsmview/ui/tabs/dashboard.py create mode 100644 dsmview/ui/tabs/disks.py create mode 100644 dsmview/ui/tabs/logs.py create mode 100644 dsmview/ui/tabs/services.py create mode 100644 dsmview/ui/theme.py create mode 100644 dsmview/ui/widgets/__init__.py create mode 100644 dsmview/ui/widgets/braille_graph.py create mode 100644 dsmview/ui/widgets/meter_bar.py create mode 100644 dsmview/ui/widgets/service_list.py create mode 100644 pyproject.toml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..17befa4 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6377f08 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.eggs/ +build/ +dist/ +.venv/ +venv/ +.env +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.DS_Store +*.log diff --git a/Formula/dsmview.rb b/Formula/dsmview.rb new file mode 100644 index 0000000..7c9b0c3 --- /dev/null +++ b/Formula/dsmview.rb @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..95b6ea4 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ddce230 --- /dev/null +++ b/README.md @@ -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`). diff --git a/dsmview/__init__.py b/dsmview/__init__.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/dsmview/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/dsmview/cli.py b/dsmview/cli.py new file mode 100644 index 0000000..79bb40f --- /dev/null +++ b/dsmview/cli.py @@ -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() diff --git a/dsmview/collectors/__init__.py b/dsmview/collectors/__init__.py new file mode 100644 index 0000000..c4ad8a2 --- /dev/null +++ b/dsmview/collectors/__init__.py @@ -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", +] diff --git a/dsmview/collectors/base.py b/dsmview/collectors/base.py new file mode 100644 index 0000000..caacbd2 --- /dev/null +++ b/dsmview/collectors/base.py @@ -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 diff --git a/dsmview/collectors/disks.py b/dsmview/collectors/disks.py new file mode 100644 index 0000000..dacf148 --- /dev/null +++ b/dsmview/collectors/disks.py @@ -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[^\]]+)\]") +_MODEL_RE = re.compile(r"Device Model:\s+(?P.+)") +_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") diff --git a/dsmview/collectors/logs.py b/dsmview/collectors/logs.py new file mode 100644 index 0000000..c9cf61d --- /dev/null +++ b/dsmview/collectors/logs.py @@ -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) diff --git a/dsmview/collectors/network.py b/dsmview/collectors/network.py new file mode 100644 index 0000000..a4db17e --- /dev/null +++ b/dsmview/collectors/network.py @@ -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 diff --git a/dsmview/collectors/services.py b/dsmview/collectors/services.py new file mode 100644 index 0000000..ab23b4d --- /dev/null +++ b/dsmview/collectors/services.py @@ -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 diff --git a/dsmview/collectors/storage.py b/dsmview/collectors/storage.py new file mode 100644 index 0000000..4c5014d --- /dev/null +++ b/dsmview/collectors/storage.py @@ -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 diff --git a/dsmview/collectors/system.py b/dsmview/collectors/system.py new file mode 100644 index 0000000..01f94d5 --- /dev/null +++ b/dsmview/collectors/system.py @@ -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 diff --git a/dsmview/ssh/__init__.py b/dsmview/ssh/__init__.py new file mode 100644 index 0000000..9be85fa --- /dev/null +++ b/dsmview/ssh/__init__.py @@ -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"] diff --git a/dsmview/ssh/connection.py b/dsmview/ssh/connection.py new file mode 100644 index 0000000..3add991 --- /dev/null +++ b/dsmview/ssh/connection.py @@ -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() diff --git a/dsmview/ssh/executor.py b/dsmview/ssh/executor.py new file mode 100644 index 0000000..382beab --- /dev/null +++ b/dsmview/ssh/executor.py @@ -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 diff --git a/dsmview/ui/__init__.py b/dsmview/ui/__init__.py new file mode 100644 index 0000000..9e543bb --- /dev/null +++ b/dsmview/ui/__init__.py @@ -0,0 +1,3 @@ +from dsmview.ui.app import DsmviewApp + +__all__ = ["DsmviewApp"] diff --git a/dsmview/ui/app.py b/dsmview/ui/app.py new file mode 100644 index 0000000..1ee8739 --- /dev/null +++ b/dsmview/ui/app.py @@ -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 "—" diff --git a/dsmview/ui/tabs/__init__.py b/dsmview/ui/tabs/__init__.py new file mode 100644 index 0000000..5662d02 --- /dev/null +++ b/dsmview/ui/tabs/__init__.py @@ -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"] diff --git a/dsmview/ui/tabs/dashboard.py b/dsmview/ui/tabs/dashboard.py new file mode 100644 index 0000000..0b8b2f4 --- /dev/null +++ b/dsmview/ui/tabs/dashboard.py @@ -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)}[/]" + ) diff --git a/dsmview/ui/tabs/disks.py b/dsmview/ui/tabs/disks.py new file mode 100644 index 0000000..22f8862 --- /dev/null +++ b/dsmview/ui/tabs/disks.py @@ -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)) diff --git a/dsmview/ui/tabs/logs.py b/dsmview/ui/tabs/logs.py new file mode 100644 index 0000000..d59e8b2 --- /dev/null +++ b/dsmview/ui/tabs/logs.py @@ -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)) diff --git a/dsmview/ui/tabs/services.py b/dsmview/ui/tabs/services.py new file mode 100644 index 0000000..4be6b6a --- /dev/null +++ b/dsmview/ui/tabs/services.py @@ -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) diff --git a/dsmview/ui/theme.py b/dsmview/ui/theme.py new file mode 100644 index 0000000..ca7755a --- /dev/null +++ b/dsmview/ui/theme.py @@ -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; +}} +""" diff --git a/dsmview/ui/widgets/__init__.py b/dsmview/ui/widgets/__init__.py new file mode 100644 index 0000000..d87b231 --- /dev/null +++ b/dsmview/ui/widgets/__init__.py @@ -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"] diff --git a/dsmview/ui/widgets/braille_graph.py b/dsmview/ui/widgets/braille_graph.py new file mode 100644 index 0000000..8912c38 --- /dev/null +++ b/dsmview/ui/widgets/braille_graph.py @@ -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) diff --git a/dsmview/ui/widgets/meter_bar.py b/dsmview/ui/widgets/meter_bar.py new file mode 100644 index 0000000..0605289 --- /dev/null +++ b/dsmview/ui/widgets/meter_bar.py @@ -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 diff --git a/dsmview/ui/widgets/service_list.py b/dsmview/ui/widgets/service_list.py new file mode 100644 index 0000000..6dfafee --- /dev/null +++ b/dsmview/ui/widgets/service_list.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e7aff44 --- /dev/null +++ b/pyproject.toml @@ -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"]