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:
commit
af63fc7b15
31 changed files with 2149 additions and 0 deletions
128
.github/workflows/release.yml
vendored
Normal file
128
.github/workflows/release.yml
vendored
Normal 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
14
.gitignore
vendored
Normal 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
50
Formula/dsmview.rb
Normal 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
21
LICENSE
Normal 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
52
README.md
Normal 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
1
dsmview/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
__version__ = "0.1.0"
|
||||
57
dsmview/cli.py
Normal file
57
dsmview/cli.py
Normal 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()
|
||||
17
dsmview/collectors/__init__.py
Normal file
17
dsmview/collectors/__init__.py
Normal 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",
|
||||
]
|
||||
24
dsmview/collectors/base.py
Normal file
24
dsmview/collectors/base.py
Normal 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
|
||||
94
dsmview/collectors/disks.py
Normal file
94
dsmview/collectors/disks.py
Normal 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")
|
||||
69
dsmview/collectors/logs.py
Normal file
69
dsmview/collectors/logs.py
Normal 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)
|
||||
76
dsmview/collectors/network.py
Normal file
76
dsmview/collectors/network.py
Normal 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
|
||||
72
dsmview/collectors/services.py
Normal file
72
dsmview/collectors/services.py
Normal 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
|
||||
88
dsmview/collectors/storage.py
Normal file
88
dsmview/collectors/storage.py
Normal 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
|
||||
179
dsmview/collectors/system.py
Normal file
179
dsmview/collectors/system.py
Normal 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
4
dsmview/ssh/__init__.py
Normal 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
233
dsmview/ssh/connection.py
Normal 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
126
dsmview/ssh/executor.py
Normal 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
3
dsmview/ui/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from dsmview.ui.app import DsmviewApp
|
||||
|
||||
__all__ = ["DsmviewApp"]
|
||||
211
dsmview/ui/app.py
Normal file
211
dsmview/ui/app.py
Normal 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 "—"
|
||||
6
dsmview/ui/tabs/__init__.py
Normal file
6
dsmview/ui/tabs/__init__.py
Normal 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"]
|
||||
116
dsmview/ui/tabs/dashboard.py
Normal file
116
dsmview/ui/tabs/dashboard.py
Normal 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
49
dsmview/ui/tabs/disks.py
Normal 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
75
dsmview/ui/tabs/logs.py
Normal 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))
|
||||
45
dsmview/ui/tabs/services.py
Normal file
45
dsmview/ui/tabs/services.py
Normal 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
163
dsmview/ui/theme.py
Normal 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;
|
||||
}}
|
||||
"""
|
||||
5
dsmview/ui/widgets/__init__.py
Normal file
5
dsmview/ui/widgets/__init__.py
Normal 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"]
|
||||
56
dsmview/ui/widgets/braille_graph.py
Normal file
56
dsmview/ui/widgets/braille_graph.py
Normal 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)
|
||||
45
dsmview/ui/widgets/meter_bar.py
Normal file
45
dsmview/ui/widgets/meter_bar.py
Normal 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
|
||||
24
dsmview/ui/widgets/service_list.py
Normal file
24
dsmview/ui/widgets/service_list.py
Normal 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
46
pyproject.toml
Normal 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"]
|
||||
Loading…
Add table
Reference in a new issue