Compare commits

..

12 commits
v0.2.0 ... main

Author SHA1 Message Date
SW
f63599af65 docs: update install instructions for Forgejo 2026-09-10 14:22:04 +02:00
github-actions[bot]
a67c5c8214 chore: refresh formula for v0.3.1 2026-06-17 18:52:15 +00:00
SW
57d3c97ed4 fix(services): scrolling, cursor persistence and visible controls
- Make ServicesTab a VerticalScroll so the service list is scrollable.
- Preserve cursor position / selected unit across periodic refreshes.
- Add visible Start/Stop/Restart buttons.
- Show selected service in the status bar.
- Focus the service table when switching to F3.
- Bump version to 0.3.1.
2026-06-17 20:51:44 +02:00
github-actions[bot]
093960ffe4 chore: refresh formula for v0.3.0 2026-06-17 18:32:40 +00:00
SW
a219c333ff feat: add Active Backup logs, log search and service management tab
- Extend LogCollector with ActiveBackup package and activity logs.
- Parse leading timestamps and render logs in timestamp/source/message columns.
- Add live text search in the Logs tab (/, ESC).
- Add F3 Services tab with systemctl-based start/stop/restart and confirmation modal.
- Keep services/disks summary inside Dashboard.
- Update README with new tabs and key bindings.
- Bump version to 0.3.0.
2026-06-17 20:32:04 +02:00
github-actions[bot]
7b1bf9ae71 chore: refresh formula for v0.2.2 2026-06-13 18:09:37 +00:00
ba3c11dc95 v0.2.2: escape log line text before feeding to Rich markup
Synology logs often embed bracketed paths like
`[/var/packages/SynoFinder/etc/etc-volume/collect.data]`. Rich
interprets the leading `[/` as a closing tag, so RichLog crashed
with MarkupError the first time the Logs tab was resized and the
deferred-render queue was flushed.

Wrap line.raw in rich.markup.escape() so brackets render as text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-13 20:09:27 +02:00
github-actions[bot]
cc2f8c6dda chore: refresh formula for v0.2.1 2026-06-13 16:34:12 +00:00
78b1712fdd v0.2.1: show all NICs with IPs, filter storage to /volumeN only
Network
- NetworkCollector now also runs `ip -o -4 addr show` and merges the
  IPv4 address into each NetSample. Dashboard shows one line per
  interface (iface + IP + rx + tx); the active one is marked with `●`
  and is what the rx/tx braille graphs track.
- Hides loopback, docker*, veth*, br-*, virbr*, tap*, tun*, ovs-system,
  dummy*, wg*. Folds the raw `ethN` away when an `ovs_ethN` exists for
  the same NIC (Open vSwitch on Synology exposes both, only the ovs_
  one carries the IP).

Storage
- Only `/volumeN` (digits) is shown. Previously bind-mounts created by
  Container Manager under `/volume1/@appdata/.../all_shares/*` and other
  subpath mounts were listed too, each repeating the pool's usage —
  visually noisy and meaningless.

Verified against DS720+ (DSM 7.3.2) and a multi-NIC NAS running OvS +
Tailscale + Docker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-13 18:33:48 +02:00
367804f681 ci: stash rendered formula before checkout in source-repo commit step
`git checkout main` aborted on the v0.2.0 release because the
freshly-rendered Formula/dsmview.rb differed from the committed
version. Save it to /tmp first, then force-checkout main, then
restore. Release tap-push step already succeeded, so brew install
worked — only the back-commit refresh was missed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-13 18:02:07 +02:00
b266bcb605 v0.2.0: consolidated dashboard, 1s refresh tier, fixed log filter, DSM 7.3+ parsers
UI
- F1 Dashboard now holds CPU/MEM/storage/network/services/disks in a
  single dense view (was split across F1/F3/F4). F2 Logs stays separate.
- CPU and MEM rendered inline (label + bar + summary on one row) so the
  dashboard fits more on screen.
- DataTables and Services list capped for compactness; services sorted
  with interesting+running first.

Refresh
- Three tiers: fast (1s) for system+network, medium (5s) for storage+
  services, slow (30s) for disks (smartctl is heavy). Each tier runs
  in an exclusive worker group so overlaps are dropped, not queued.

Log filter
- Bindings (a / 1-4) moved from LogsTab container to the App, so they
  fire regardless of focus. Pressing one auto-switches to the logs tab.

Synology DSM 7.3+ compatibility (verified against DS720+ / DSM 7.3.2)
- DSM version read from /etc/VERSION (synoinfo.conf no longer has
  productversion on newer DSM).
- Disks discovered as /dev/sata{N} as well as /dev/sd{a..z} and NVMe;
  smartctl runs with -d sat so SMART data is actually readable.
- SAS-style smartctl output (Vendor:/Product:) parsed as a fallback
  when Device Model: is absent.
- Services collected via plain systemctl list-units, since
  synoservicectl/synosystemctl are gone on recent DSM builds.
- Network primary() picks the interface with the most accumulated
  traffic instead of hard-coded eth0.

Cleanup
- Removed ConfirmDialog and service start/stop/restart actions — the
  app is now truly read-only, matching the stated philosophy and
  eliminating a key conflict with `r`=refresh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-13 17:57:54 +02:00
github-actions[bot]
3fe7cff45e chore: refresh formula for v0.1.0 2026-06-13 15:11:57 +00:00
14 changed files with 608 additions and 86 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -8,7 +8,10 @@ from dsmview.collectors.base import Collector
@dataclass(slots=True) @dataclass(slots=True)
class ServiceInfo: class ServiceInfo:
# Human-readable name shown in the UI.
name: str name: str
# Full systemd unit name, e.g. "pkg-ActiveBackup-logd.service".
unit: str = ""
running: bool = False running: bool = False
description: str = "" description: str = ""
@ -65,7 +68,14 @@ class ServicesCollector(Collector[ServicesSnapshot]):
# smbd shows as smbd, not pkg-smbd. # smbd shows as smbd, not pkg-smbd.
display = name[4:] if name.startswith("pkg-") else name display = name[4:] if name.startswith("pkg-") else name
running = sub_state == "running" running = sub_state == "running"
out.append(ServiceInfo(name=display, running=running, description=desc.strip().strip('"'))) out.append(
ServiceInfo(
name=display,
unit=unit,
running=running,
description=desc.strip().strip('"'),
)
)
# Sort: interesting services first, then running ones, then the rest. # Sort: interesting services first, then running ones, then the rest.
def sort_key(s: ServiceInfo) -> tuple: def sort_key(s: ServiceInfo) -> tuple:
interesting = 0 if s.name in INTERESTING else 1 interesting = 0 if s.name in INTERESTING else 1

View file

@ -1,10 +1,13 @@
from __future__ import annotations from __future__ import annotations
import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import List from typing import List
from dsmview.collectors.base import Collector from dsmview.collectors.base import Collector
_VOLUME_RE = re.compile(r"^/volume\d+$")
@dataclass(slots=True) @dataclass(slots=True)
class VolumeInfo: class VolumeInfo:
@ -51,9 +54,10 @@ class StorageCollector(Collector[StorageSnapshot]):
if len(parts) < 6: if len(parts) < 6:
continue continue
device, size, used, avail, pct, mount = parts[:6] device, size, used, avail, pct, mount = parts[:6]
# Only show user-facing data volumes. /, /tmp/* and friends # Only show actual storage pools — /volume1, /volume2, etc.
# are noise on Synology — they're the DSM system partition. # Container Manager bind-mounts (/volume1/@appdata/...) and
if not mount.startswith("/volume"): # system mounts repeat the same usage and just add noise.
if not _VOLUME_RE.match(mount):
continue continue
if device in ("tmpfs", "devtmpfs", "overlay", "none"): if device in ("tmpfs", "devtmpfs", "overlay", "none"):
continue continue

View file

@ -18,7 +18,7 @@ from dsmview.collectors import (
from dsmview.ssh.connection import NasConnection from dsmview.ssh.connection import NasConnection
from dsmview.ssh.executor import Executor from dsmview.ssh.executor import Executor
from dsmview.ui import theme from dsmview.ui import theme
from dsmview.ui.tabs import DashboardTab, LogsTab from dsmview.ui.tabs import DashboardTab, LogsTab, ServicesTab
# Three refresh tiers — fast for live metrics, medium for storage/services, # Three refresh tiers — fast for live metrics, medium for storage/services,
@ -37,11 +37,17 @@ class DsmviewApp(App):
Binding("r", "refresh_now", "Refresh"), Binding("r", "refresh_now", "Refresh"),
Binding("f1", "switch_tab('dashboard')", "Dash"), Binding("f1", "switch_tab('dashboard')", "Dash"),
Binding("f2", "switch_tab('logs')", "Logs"), Binding("f2", "switch_tab('logs')", "Logs"),
Binding("f3", "switch_tab('services')", "Services"),
Binding("a", "filter('ALL')", "All", show=False), Binding("a", "filter('ALL')", "All", show=False),
Binding("1", "filter('ERROR')", "Err", show=False), Binding("1", "filter('ERROR')", "Err", show=False),
Binding("2", "filter('WARN')", "Warn", show=False), Binding("2", "filter('WARN')", "Warn", show=False),
Binding("3", "filter('SECURITY')", "Sec", show=False), Binding("3", "filter('SECURITY')", "Sec", show=False),
Binding("4", "filter('INFO')", "Info", show=False), Binding("4", "filter('INFO')", "Info", show=False),
Binding("/", "focus_search", "Search", show=False),
Binding("escape", "clear_search", "ClearSearch", show=False),
Binding("s", "start_service", "Start", show=False),
Binding("S", "stop_service", "Stop", show=False),
Binding("R", "restart_service", "Restart", show=False),
] ]
def __init__(self, connection: NasConnection) -> None: def __init__(self, connection: NasConnection) -> None:
@ -67,6 +73,9 @@ class DsmviewApp(App):
with TabPane("F2 Logs", id="logs"): with TabPane("F2 Logs", id="logs"):
self.logs_tab = LogsTab() self.logs_tab = LogsTab()
yield self.logs_tab yield self.logs_tab
with TabPane("F3 Services", id="services"):
self.services_tab = ServicesTab(self.executor)
yield self.services_tab
yield Footer() yield Footer()
def on_mount(self) -> None: def on_mount(self) -> None:
@ -89,11 +98,35 @@ class DsmviewApp(App):
def action_switch_tab(self, tab_id: str) -> None: def action_switch_tab(self, tab_id: str) -> None:
self.query_one(TabbedContent).active = tab_id self.query_one(TabbedContent).active = tab_id
if tab_id == "services":
self.services_tab.focus_table()
def action_filter(self, name: str) -> None: def action_filter(self, name: str) -> None:
self.query_one(TabbedContent).active = "logs" self.query_one(TabbedContent).active = "logs"
self.logs_tab.set_filter(name) self.logs_tab.set_filter(name)
def action_focus_search(self) -> None:
self.query_one(TabbedContent).active = "logs"
self.logs_tab.show_search()
def action_clear_search(self) -> None:
self.logs_tab.hide_search()
def _on_services_tab(self, action_name: str) -> None:
active = self.query_one(TabbedContent).active
if active != "services":
return
getattr(self.services_tab, action_name)()
def action_start_service(self) -> None:
self._on_services_tab("action_start_service")
def action_stop_service(self) -> None:
self._on_services_tab("action_stop_service")
def action_restart_service(self) -> None:
self._on_services_tab("action_restart_service")
def action_refresh_now(self) -> None: def action_refresh_now(self) -> None:
self._tick_fast() self._tick_fast()
self._tick_medium() self._tick_medium()
@ -133,6 +166,7 @@ class DsmviewApp(App):
return return
self.dashboard.update_storage(storage_snap) self.dashboard.update_storage(storage_snap)
self.dashboard.update_services(services_snap.services) self.dashboard.update_services(services_snap.services)
self.services_tab.update(services_snap.services)
async def _refresh_slow(self) -> None: async def _refresh_slow(self) -> None:
try: try:

View file

@ -1,4 +1,5 @@
from dsmview.ui.tabs.dashboard import DashboardTab from dsmview.ui.tabs.dashboard import DashboardTab
from dsmview.ui.tabs.logs import LogsTab from dsmview.ui.tabs.logs import LogsTab
from dsmview.ui.tabs.services import ServicesTab
__all__ = ["DashboardTab", "LogsTab"] __all__ = ["DashboardTab", "LogsTab", "ServicesTab"]

View file

@ -137,11 +137,22 @@ class DashboardTab(VerticalScroll):
return return
self.net_rx_graph.push(primary.rx_bps) self.net_rx_graph.push(primary.rx_bps)
self.net_tx_graph.push(primary.tx_bps) self.net_tx_graph.push(primary.tx_bps)
self.net_summary.update( # One line per interface so multi-NIC setups (LACP, OvS, Tailscale)
f" [bold]{primary.iface}[/] " # are visible. The primary is highlighted because the graph
f"[{theme.NET_DOWN}]↓ {_fmt_bytes_per_sec(primary.rx_bps)}[/] " # underneath tracks it.
f"[{theme.NET_UP}]↑ {_fmt_bytes_per_sec(primary.tx_bps)}[/]" max_name = max((len(s.iface) for s in s.samples), default=8)
) max_ip = max((len(s.ip) for s in s.samples), default=0)
lines = []
for sample in s.samples:
iface = sample.iface.ljust(max_name)
ip = (sample.ip or "—").ljust(max_ip if max_ip else 1)
tag = "[bold]●[/]" if sample is primary else " "
lines.append(
f" {tag} [bold]{iface}[/] [grey70]{ip}[/] "
f"[{theme.NET_DOWN}]↓ {_fmt_bytes_per_sec(sample.rx_bps)}[/] "
f"[{theme.NET_UP}]↑ {_fmt_bytes_per_sec(sample.tx_bps)}[/]"
)
self.net_summary.update("\n".join(lines))
SERVICES_LIMIT = 30 SERVICES_LIMIT = 30

View file

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

219
dsmview/ui/tabs/services.py Normal file
View file

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

View file

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "dsmview" name = "dsmview"
version = "0.2.0" version = "0.3.1"
description = "TUI SSH monitor for Synology DSM 7.x" description = "TUI SSH monitor for Synology DSM 7.x"
readme = "README.md" readme = "README.md"
license = { text = "MIT" } license = { text = "MIT" }