Compare commits
1 commit
| Author | SHA1 | Date | |
|---|---|---|---|
| b1e0ad0585 |
14 changed files with 86 additions and 608 deletions
7
.github/workflows/release.yml
vendored
7
.github/workflows/release.yml
vendored
|
|
@ -155,14 +155,11 @@ jobs:
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
# Stash the newly rendered formula before switching branches —
|
|
||||||
# otherwise `git checkout main` aborts when the working-tree
|
|
||||||
# formula differs from main's committed version.
|
|
||||||
cp Formula/dsmview.rb /tmp/dsmview.rb
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
git config user.name "github-actions[bot]"
|
git config user.name "github-actions[bot]"
|
||||||
git fetch origin main
|
git fetch origin main
|
||||||
git checkout -f main
|
git checkout main
|
||||||
|
cp Formula/dsmview.rb /tmp/dsmview.rb
|
||||||
git pull --rebase origin main || true
|
git pull --rebase origin main || true
|
||||||
cp /tmp/dsmview.rb Formula/dsmview.rb
|
cp /tmp/dsmview.rb Formula/dsmview.rb
|
||||||
git add Formula/dsmview.rb
|
git add Formula/dsmview.rb
|
||||||
|
|
|
||||||
|
|
@ -3,113 +3,42 @@ class Dsmview < Formula
|
||||||
|
|
||||||
desc "TUI SSH monitor for Synology DSM 7.x"
|
desc "TUI SSH monitor for Synology DSM 7.x"
|
||||||
homepage "https://github.com/janvete/dsmview"
|
homepage "https://github.com/janvete/dsmview"
|
||||||
url "https://github.com/janvete/dsmview/archive/refs/tags/v0.3.1.tar.gz"
|
url "https://github.com/janvete/dsmview/archive/refs/tags/v0.1.0.tar.gz"
|
||||||
sha256 "6f0fcda846ee254604b87e06f3eaf20f58cdb148573cc30cb0fc7f31e922d665"
|
sha256 "0000000000000000000000000000000000000000000000000000000000000000"
|
||||||
license "MIT"
|
license "MIT"
|
||||||
head "https://github.com/janvete/dsmview.git", branch: "main"
|
head "https://github.com/janvete/dsmview.git", branch: "main"
|
||||||
|
|
||||||
depends_on "python@3.12"
|
depends_on "python@3.12"
|
||||||
depends_on "rust" => :build
|
depends_on "rust" => :build # cryptography wheels build dep on some platforms
|
||||||
|
|
||||||
resource "PyNaCl" do
|
# PYTHON_RESOURCES_BEGIN
|
||||||
url "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz"
|
# The release workflow regenerates the resource blocks below using
|
||||||
sha256 "018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c"
|
# homebrew-pypi-poet whenever a new tag is pushed.
|
||||||
end
|
resource "textual" do
|
||||||
|
url "https://files.pythonhosted.org/packages/source/t/textual/textual-0.86.3.tar.gz"
|
||||||
resource "Pygments" do
|
sha256 "0000000000000000000000000000000000000000000000000000000000000000"
|
||||||
url "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz"
|
|
||||||
sha256 "6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"
|
|
||||||
end
|
|
||||||
|
|
||||||
resource "bcrypt" do
|
|
||||||
url "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz"
|
|
||||||
sha256 "f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd"
|
|
||||||
end
|
|
||||||
|
|
||||||
resource "cffi" do
|
|
||||||
url "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz"
|
|
||||||
sha256 "44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"
|
|
||||||
end
|
|
||||||
|
|
||||||
resource "click" do
|
|
||||||
url "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz"
|
|
||||||
sha256 "918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96"
|
|
||||||
end
|
|
||||||
|
|
||||||
resource "cryptography" do
|
|
||||||
url "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz"
|
|
||||||
sha256 "f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493"
|
|
||||||
end
|
|
||||||
|
|
||||||
resource "invoke" do
|
|
||||||
url "https://files.pythonhosted.org/packages/33/f6/227c48c5fe47fa178ccf1fda8f047d16c97ba926567b661e9ce2045c600c/invoke-3.0.3.tar.gz"
|
|
||||||
sha256 "437b6a622223824380bfb4e64f612711a6b648c795f565efc8625af66fb57f0c"
|
|
||||||
end
|
|
||||||
|
|
||||||
resource "linkify-it-py" do
|
|
||||||
url "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz"
|
|
||||||
sha256 "43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b"
|
|
||||||
end
|
|
||||||
|
|
||||||
resource "markdown-it-py" do
|
|
||||||
url "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz"
|
|
||||||
sha256 "04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49"
|
|
||||||
end
|
|
||||||
|
|
||||||
resource "mdit-py-plugins" do
|
|
||||||
url "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz"
|
|
||||||
sha256 "a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0"
|
|
||||||
end
|
|
||||||
|
|
||||||
resource "mdurl" do
|
|
||||||
url "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz"
|
|
||||||
sha256 "bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
resource "paramiko" do
|
resource "paramiko" do
|
||||||
url "https://files.pythonhosted.org/packages/62/93/dcc25d52f49022ae6175d15e6bd751f1acc99b98bc61fc55e5155a7be2e7/paramiko-5.0.0.tar.gz"
|
url "https://files.pythonhosted.org/packages/source/p/paramiko/paramiko-3.5.0.tar.gz"
|
||||||
sha256 "36763b5b95c2a0dcfdf1abc48e48156ee425b21efe2f0e787c2dd5a95c0e5e79"
|
sha256 "0000000000000000000000000000000000000000000000000000000000000000"
|
||||||
end
|
end
|
||||||
|
|
||||||
resource "platformdirs" do
|
resource "click" do
|
||||||
url "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz"
|
url "https://files.pythonhosted.org/packages/source/c/click/click-8.1.7.tar.gz"
|
||||||
sha256 "31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7"
|
sha256 "0000000000000000000000000000000000000000000000000000000000000000"
|
||||||
end
|
end
|
||||||
|
|
||||||
resource "plotext" do
|
resource "plotext" do
|
||||||
url "https://files.pythonhosted.org/packages/c9/d7/f75f397af966fe252d0d34ffd3cae765317fce2134f925f95e7d6725d1ce/plotext-5.3.2.tar.gz"
|
url "https://files.pythonhosted.org/packages/source/p/plotext/plotext-5.3.2.tar.gz"
|
||||||
sha256 "52d1e932e67c177bf357a3f0fe6ce14d1a96f7f7d5679d7b455b929df517068e"
|
sha256 "0000000000000000000000000000000000000000000000000000000000000000"
|
||||||
end
|
|
||||||
|
|
||||||
resource "pycparser" do
|
|
||||||
url "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz"
|
|
||||||
sha256 "600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"
|
|
||||||
end
|
|
||||||
|
|
||||||
resource "rich" do
|
|
||||||
url "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz"
|
|
||||||
sha256 "edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36"
|
|
||||||
end
|
|
||||||
|
|
||||||
resource "textual" do
|
|
||||||
url "https://files.pythonhosted.org/packages/9b/7a/c519db0aba5024f86e71e9631810bfdd6866ed2c8695bd7fa34b90e7ef59/textual-8.2.7.tar.gz"
|
|
||||||
sha256 "658f568ff81e30ed43890c3e07520390e5cf1b4763822006e060656b0a88f105"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
resource "textual-plotext" do
|
resource "textual-plotext" do
|
||||||
url "https://files.pythonhosted.org/packages/9a/b0/e4e0f38df057db778252db0dd2c08522d7222b8537b6a0181d797b9044bd/textual_plotext-1.0.1.tar.gz"
|
url "https://files.pythonhosted.org/packages/source/t/textual-plotext/textual_plotext-0.2.1.tar.gz"
|
||||||
sha256 "836f53a3316756609e194129a35c2875638e7958c261f541e0a794f7c98011be"
|
sha256 "0000000000000000000000000000000000000000000000000000000000000000"
|
||||||
end
|
|
||||||
|
|
||||||
resource "typing_extensions" do
|
|
||||||
url "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz"
|
|
||||||
sha256 "0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"
|
|
||||||
end
|
|
||||||
|
|
||||||
resource "uc-micro-py" do
|
|
||||||
url "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz"
|
|
||||||
sha256 "c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811"
|
|
||||||
end
|
end
|
||||||
|
# PYTHON_RESOURCES_END
|
||||||
|
|
||||||
def install
|
def install
|
||||||
virtualenv_install_with_resources
|
virtualenv_install_with_resources
|
||||||
|
|
|
||||||
48
README.md
48
README.md
|
|
@ -2,31 +2,11 @@
|
||||||
|
|
||||||
TUI SSH monitor for Synology DSM 7.x — read-only dashboard inspired by `btop`.
|
TUI SSH monitor for Synology DSM 7.x — read-only dashboard inspired by `btop`.
|
||||||
|
|
||||||
## Installation
|
```
|
||||||
|
brew tap janvete/tools
|
||||||
### macOS (Homebrew)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew tap janvete/tools https://git.janvete.com/seth/homebrew-tools.git
|
|
||||||
brew install dsmview
|
brew install dsmview
|
||||||
```
|
```
|
||||||
|
|
||||||
### Debian / Ubuntu (APT)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
echo "deb [trusted=yes] https://git.janvete.com/seth/apt/raw/branch/main ./" | sudo tee /etc/apt/sources.list.d/janvete.list
|
|
||||||
sudo apt update
|
|
||||||
sudo apt install dsmview
|
|
||||||
```
|
|
||||||
|
|
||||||
### From source
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone ssh://git@git-ssh.janvete.com:554/seth/dsmview.git
|
|
||||||
cd dsmview
|
|
||||||
pip install .
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
@ -49,28 +29,20 @@ service — and that requires explicit confirmation in the UI.
|
||||||
## Tabs
|
## Tabs
|
||||||
|
|
||||||
- **F1 Dashboard** — CPU history graph, memory, storage pools, network
|
- **F1 Dashboard** — CPU history graph, memory, storage pools, network
|
||||||
throughput, load average, temperatures, services summary and disk health.
|
throughput, load average, temperatures.
|
||||||
- **F2 Logs** — live tail of system, auth and package logs (including
|
- **F2 Logs** — live tail of `/var/log/messages` and `/var/log/auth.log`
|
||||||
Active Backup) with severity filters and text search.
|
with severity filters (ALL / ERROR / WARN / SECURITY / INFO).
|
||||||
- **F3 Services** — `systemctl` service list with up/down status and
|
- **F3 Services** — `synoservicectl --list` table with up/down status and
|
||||||
start/stop/restart actions.
|
restart/stop/start actions.
|
||||||
|
- **F4 Disks** — per-disk model/size/temp/health from `smartctl` plus RAID
|
||||||
|
status from `/proc/mdstat`.
|
||||||
|
|
||||||
## Keys
|
## Keys
|
||||||
|
|
||||||
```
|
```
|
||||||
F1..F3 switch tab
|
F1..F4 switch tab
|
||||||
r refresh now
|
r refresh now
|
||||||
q quit
|
q quit
|
||||||
/ focus log search
|
|
||||||
ESC clear log search
|
|
||||||
Logs tab:
|
|
||||||
a / 1-4 severity filter (All / Error / Warn / Security / Info)
|
|
||||||
Services tab:
|
|
||||||
↑/↓ select service
|
|
||||||
s start
|
|
||||||
S stop
|
|
||||||
R restart
|
|
||||||
(or use the Start / Stop / Restart buttons)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
__version__ = "0.3.1"
|
__version__ = "0.2.0"
|
||||||
|
|
|
||||||
|
|
@ -16,36 +16,10 @@ class Severity(str, Enum):
|
||||||
OTHER = "OTHER"
|
OTHER = "OTHER"
|
||||||
|
|
||||||
|
|
||||||
_ERROR_RE = re.compile(
|
_ERROR_RE = re.compile(r"\b(error|fail(?:ed|ure)?|critical|EXT4-fs error|I/O error|RAID degraded|panic|oom)\b", re.IGNORECASE)
|
||||||
r"\b(error|fail(?:ed|ure)?|critical|EXT4-fs error|I/O error|RAID degraded|"
|
_WARN_RE = re.compile(r"\b(warn(?:ing)?|temperature|fan speed|link down|throttle)\b", re.IGNORECASE)
|
||||||
r"panic|oom|crashed|corrupt|cannot|unable)\b",
|
_SECURITY_RE = re.compile(r"\b(Failed password|Invalid user|authentication failure|sudo|session opened|session closed)\b")
|
||||||
re.IGNORECASE,
|
_INFO_RE = re.compile(r"\b(completed|started|stopped|success|updated|finished)\b", re.IGNORECASE)
|
||||||
)
|
|
||||||
_WARN_RE = re.compile(
|
|
||||||
r"\b(warn(?:ing)?|temperature|fan speed|link down|throttle|offline)\b",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_SECURITY_RE = re.compile(
|
|
||||||
r"\b(Failed password|Invalid user|authentication failure|sudo|"
|
|
||||||
r"session opened|session closed)\b"
|
|
||||||
)
|
|
||||||
_INFO_RE = re.compile(
|
|
||||||
r"\b(completed|started|stopped|success|updated|finished|backup task)\b",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Try to strip leading syslog timestamp so we can show a short date prefix.
|
|
||||||
_SYSLOG_TS_RE = re.compile(
|
|
||||||
r"^([A-Z][a-z]{2}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}|"
|
|
||||||
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[\+\-]\d{2}:\d{2}|"
|
|
||||||
r"\d{4}/\d{2}/\d{2}\s+\d{2}:\d{2}:\d{2})"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class LogSource:
|
|
||||||
path: str
|
|
||||||
label: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
|
|
@ -53,8 +27,6 @@ class LogLine:
|
||||||
raw: str
|
raw: str
|
||||||
source: str
|
source: str
|
||||||
severity: Severity = Severity.OTHER
|
severity: Severity = Severity.OTHER
|
||||||
timestamp: str = ""
|
|
||||||
message: str = ""
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def classify(cls, raw: str, source: str) -> "LogLine":
|
def classify(cls, raw: str, source: str) -> "LogLine":
|
||||||
|
|
@ -69,47 +41,29 @@ class LogLine:
|
||||||
sev = Severity.WARN
|
sev = Severity.WARN
|
||||||
elif _INFO_RE.search(raw):
|
elif _INFO_RE.search(raw):
|
||||||
sev = Severity.INFO
|
sev = Severity.INFO
|
||||||
|
return cls(raw=raw, source=source, severity=sev)
|
||||||
# Extract a leading timestamp, if present.
|
|
||||||
ts_match = _SYSLOG_TS_RE.match(raw)
|
|
||||||
timestamp = ts_match.group(1) if ts_match else ""
|
|
||||||
message = raw[ts_match.end() :].lstrip() if ts_match else raw
|
|
||||||
return cls(
|
|
||||||
raw=raw,
|
|
||||||
source=source,
|
|
||||||
severity=sev,
|
|
||||||
timestamp=timestamp,
|
|
||||||
message=message,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class LogCollector(Collector[List[LogLine]]):
|
class LogCollector(Collector[List[LogLine]]):
|
||||||
name = "logs"
|
name = "logs"
|
||||||
|
|
||||||
# (path, short_label). Paths are checked at runtime so missing files
|
SOURCES = (
|
||||||
# simply yield no lines.
|
("/var/log/messages", "messages"),
|
||||||
SOURCES: tuple[LogSource, ...] = (
|
("/var/log/auth.log", "auth"),
|
||||||
LogSource("/var/log/messages", "messages"),
|
|
||||||
LogSource("/var/log/auth.log", "auth"),
|
|
||||||
LogSource("/var/log/packages/ActiveBackup.log", "abb-pkg"),
|
|
||||||
# The ABB activity log is usually under /volumeX/@ActiveBackup/log.
|
|
||||||
# The package exposes a symlink at /var/packages/ActiveBackup/target/log.
|
|
||||||
LogSource("/var/packages/ActiveBackup/target/log/activity.log", "abb-activity"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def collect(self) -> List[LogLine]:
|
async def collect(self) -> List[LogLine]:
|
||||||
cmds = [f"tail -n 200 {src.path} 2>/dev/null || true" for src in self.SOURCES]
|
cmds = [f"tail -n 200 {path} 2>/dev/null || true" for path, _ in self.SOURCES]
|
||||||
results = await self.executor.gather(cmds, timeout=10.0)
|
results = await self.executor.gather(cmds, timeout=10.0)
|
||||||
lines: List[LogLine] = []
|
lines: List[LogLine] = []
|
||||||
for src, res in zip(self.SOURCES, results):
|
for (_, source), res in zip(self.SOURCES, results):
|
||||||
for raw in res.stdout.splitlines():
|
for raw in res.stdout.splitlines():
|
||||||
if raw.strip():
|
if raw.strip():
|
||||||
lines.append(LogLine.classify(raw, src.label))
|
lines.append(LogLine.classify(raw, source))
|
||||||
# Newest last so the RichLog scrolls to the bottom naturally.
|
|
||||||
return lines
|
return lines
|
||||||
|
|
||||||
async def tail(self, source_index: int = 0) -> AsyncIterator[LogLine]:
|
async def tail(self, source_index: int = 0) -> AsyncIterator[LogLine]:
|
||||||
src = self.SOURCES[source_index]
|
path, source = self.SOURCES[source_index]
|
||||||
async for raw in self.executor.tail(src.path):
|
async for raw in self.executor.tail(path):
|
||||||
if raw.strip():
|
if raw.strip():
|
||||||
yield LogLine.classify(raw, src.label)
|
yield LogLine.classify(raw, source)
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,11 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from dsmview.collectors.base import Collector
|
from dsmview.collectors.base import Collector
|
||||||
|
|
||||||
# Interface prefixes we never want to surface — virtual, internal,
|
|
||||||
# bridge, container, VPN.
|
|
||||||
_HIDE_PREFIXES = (
|
|
||||||
"lo", "docker", "veth", "br-", "virbr", "tap", "tun",
|
|
||||||
"sit", "ovs-system", "dummy", "wg",
|
|
||||||
)
|
|
||||||
# Open vSwitch on Synology exposes the real NIC twice — as `ethN` and
|
|
||||||
# `ovs_ethN`. The ovs_ wrapper is the one with the IP, so prefer it.
|
|
||||||
_OVS_RE = re.compile(r"^ovs_(eth\d+)$")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class IfaceCounters:
|
class IfaceCounters:
|
||||||
|
|
@ -32,7 +21,6 @@ class NetSample:
|
||||||
tx_bps: float = 0.0
|
tx_bps: float = 0.0
|
||||||
rx_total: int = 0
|
rx_total: int = 0
|
||||||
tx_total: int = 0
|
tx_total: int = 0
|
||||||
ip: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
|
|
@ -40,13 +28,16 @@ class NetworkSnapshot:
|
||||||
samples: List[NetSample] = field(default_factory=list)
|
samples: List[NetSample] = field(default_factory=list)
|
||||||
|
|
||||||
def primary(self) -> Optional[NetSample]:
|
def primary(self) -> Optional[NetSample]:
|
||||||
"""Pick the interface with the most accumulated traffic. Falls
|
# Pick the interface with the most total traffic. Falls back to
|
||||||
back to the first sample if no counters have moved yet."""
|
# eth0-style names when no counters have accumulated 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]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -58,38 +49,18 @@ class NetworkCollector(Collector[NetworkSnapshot]):
|
||||||
self._prev: Dict[str, IfaceCounters] = {}
|
self._prev: Dict[str, IfaceCounters] = {}
|
||||||
|
|
||||||
async def collect(self) -> NetworkSnapshot:
|
async def collect(self) -> NetworkSnapshot:
|
||||||
netdev, ipaddr = await self.executor.gather([
|
result = await self.executor.run("cat /proc/net/dev")
|
||||||
"cat /proc/net/dev",
|
return self._parse(result.stdout)
|
||||||
"ip -o -4 addr show 2>/dev/null || true",
|
|
||||||
])
|
|
||||||
ips = self._parse_ip_addrs(ipaddr.stdout)
|
|
||||||
return self._merge(netdev.stdout, ips)
|
|
||||||
|
|
||||||
@staticmethod
|
def _parse(self, text: str) -> NetworkSnapshot:
|
||||||
def _parse_ip_addrs(text: str) -> Dict[str, str]:
|
|
||||||
"""Map interface name → first IPv4 address."""
|
|
||||||
out: Dict[str, str] = {}
|
|
||||||
for line in text.splitlines():
|
|
||||||
parts = line.split()
|
|
||||||
if len(parts) < 4 or parts[2] != "inet":
|
|
||||||
continue
|
|
||||||
iface = parts[1]
|
|
||||||
cidr = parts[3]
|
|
||||||
addr = cidr.split("/", 1)[0]
|
|
||||||
out.setdefault(iface, addr)
|
|
||||||
return out
|
|
||||||
|
|
||||||
def _merge(self, netdev_text: str, ips: Dict[str, str]) -> NetworkSnapshot:
|
|
||||||
snap = NetworkSnapshot()
|
snap = NetworkSnapshot()
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
# First pass: read counters for all visible non-hidden interfaces.
|
for line in text.splitlines():
|
||||||
candidates: Dict[str, NetSample] = {}
|
|
||||||
for line in netdev_text.splitlines():
|
|
||||||
if ":" not in line:
|
if ":" not in line:
|
||||||
continue
|
continue
|
||||||
name, _, rest = line.partition(":")
|
name, _, rest = line.partition(":")
|
||||||
iface = name.strip()
|
iface = name.strip()
|
||||||
if self._is_hidden(iface):
|
if iface in ("lo",) or iface.startswith("docker") or iface.startswith("veth"):
|
||||||
continue
|
continue
|
||||||
fields = rest.split()
|
fields = rest.split()
|
||||||
if len(fields) < 16:
|
if len(fields) < 16:
|
||||||
|
|
@ -101,33 +72,12 @@ class NetworkCollector(Collector[NetworkSnapshot]):
|
||||||
continue
|
continue
|
||||||
current = IfaceCounters(rx_bytes=rx, tx_bytes=tx, timestamp=now)
|
current = IfaceCounters(rx_bytes=rx, tx_bytes=tx, timestamp=now)
|
||||||
prev = self._prev.get(iface)
|
prev = self._prev.get(iface)
|
||||||
sample = NetSample(iface=iface, rx_total=rx, tx_total=tx, ip=ips.get(iface, ""))
|
sample = NetSample(iface=iface, rx_total=rx, tx_total=tx)
|
||||||
if prev is not None:
|
if prev is not None:
|
||||||
dt = current.timestamp - prev.timestamp
|
dt = current.timestamp - prev.timestamp
|
||||||
if dt > 0:
|
if dt > 0:
|
||||||
sample.rx_bps = max(0.0, (rx - prev.rx_bytes) / dt)
|
sample.rx_bps = max(0.0, (rx - prev.rx_bytes) / dt)
|
||||||
sample.tx_bps = max(0.0, (tx - prev.tx_bytes) / dt)
|
sample.tx_bps = max(0.0, (tx - prev.tx_bytes) / dt)
|
||||||
candidates[iface] = sample
|
snap.samples.append(sample)
|
||||||
self._prev[iface] = current
|
self._prev[iface] = current
|
||||||
|
|
||||||
# OvS hides the real NIC behind both `ethN` and `ovs_ethN`. The
|
|
||||||
# ovs_ entry is what carries the IP; drop the raw ethN twin when
|
|
||||||
# both exist so we don't show a duplicate row.
|
|
||||||
for name in list(candidates):
|
|
||||||
m = _OVS_RE.match(name)
|
|
||||||
if m and m.group(1) in candidates:
|
|
||||||
candidates.pop(m.group(1), None)
|
|
||||||
|
|
||||||
# Sort: interfaces with an IP first, then by total traffic desc.
|
|
||||||
snap.samples = sorted(
|
|
||||||
candidates.values(),
|
|
||||||
key=lambda s: (0 if s.ip else 1, -(s.rx_total + s.tx_total), s.iface),
|
|
||||||
)
|
|
||||||
return snap
|
return snap
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _is_hidden(iface: str) -> bool:
|
|
||||||
for prefix in _HIDE_PREFIXES:
|
|
||||||
if iface == prefix or iface.startswith(prefix):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,7 @@ 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 = ""
|
||||||
|
|
||||||
|
|
@ -68,14 +65,7 @@ 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(
|
out.append(ServiceInfo(name=display, running=running, description=desc.strip().strip('"')))
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,10 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from dsmview.collectors.base import Collector
|
from dsmview.collectors.base import Collector
|
||||||
|
|
||||||
_VOLUME_RE = re.compile(r"^/volume\d+$")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class VolumeInfo:
|
class VolumeInfo:
|
||||||
|
|
@ -54,10 +51,9 @@ class StorageCollector(Collector[StorageSnapshot]):
|
||||||
if len(parts) < 6:
|
if len(parts) < 6:
|
||||||
continue
|
continue
|
||||||
device, size, used, avail, pct, mount = parts[:6]
|
device, size, used, avail, pct, mount = parts[:6]
|
||||||
# Only show actual storage pools — /volume1, /volume2, etc.
|
# Only show user-facing data volumes. /, /tmp/* and friends
|
||||||
# Container Manager bind-mounts (/volume1/@appdata/...) and
|
# are noise on Synology — they're the DSM system partition.
|
||||||
# system mounts repeat the same usage and just add noise.
|
if not mount.startswith("/volume"):
|
||||||
if not _VOLUME_RE.match(mount):
|
|
||||||
continue
|
continue
|
||||||
if device in ("tmpfs", "devtmpfs", "overlay", "none"):
|
if device in ("tmpfs", "devtmpfs", "overlay", "none"):
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -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, ServicesTab
|
from dsmview.ui.tabs import DashboardTab, LogsTab
|
||||||
|
|
||||||
|
|
||||||
# Three refresh tiers — fast for live metrics, medium for storage/services,
|
# Three refresh tiers — fast for live metrics, medium for storage/services,
|
||||||
|
|
@ -37,17 +37,11 @@ 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:
|
||||||
|
|
@ -73,9 +67,6 @@ 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:
|
||||||
|
|
@ -98,35 +89,11 @@ 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()
|
||||||
|
|
@ -166,7 +133,6 @@ 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:
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
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", "ServicesTab"]
|
__all__ = ["DashboardTab", "LogsTab"]
|
||||||
|
|
|
||||||
|
|
@ -137,22 +137,11 @@ 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)
|
||||||
# One line per interface so multi-NIC setups (LACP, OvS, Tailscale)
|
self.net_summary.update(
|
||||||
# are visible. The primary is highlighted because the graph
|
f" [bold]{primary.iface}[/] "
|
||||||
# underneath tracks it.
|
f"[{theme.NET_DOWN}]↓ {_fmt_bytes_per_sec(primary.rx_bps)}[/] "
|
||||||
max_name = max((len(s.iface) for s in s.samples), default=8)
|
f"[{theme.NET_UP}]↑ {_fmt_bytes_per_sec(primary.tx_bps)}[/]"
|
||||||
max_ip = max((len(s.ip) for s in s.samples), default=0)
|
|
||||||
lines = []
|
|
||||||
for sample in s.samples:
|
|
||||||
iface = sample.iface.ljust(max_name)
|
|
||||||
ip = (sample.ip or "—").ljust(max_ip if max_ip else 1)
|
|
||||||
tag = "[bold]●[/]" if sample is primary else " "
|
|
||||||
lines.append(
|
|
||||||
f" {tag} [bold]{iface}[/] [grey70]{ip}[/] "
|
|
||||||
f"[{theme.NET_DOWN}]↓ {_fmt_bytes_per_sec(sample.rx_bps)}[/] "
|
|
||||||
f"[{theme.NET_UP}]↑ {_fmt_bytes_per_sec(sample.tx_bps)}[/]"
|
|
||||||
)
|
)
|
||||||
self.net_summary.update("\n".join(lines))
|
|
||||||
|
|
||||||
SERVICES_LIMIT = 30
|
SERVICES_LIMIT = 30
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,9 @@ 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 Input, RichLog, Static
|
from textual.widgets import RichLog, Static
|
||||||
|
|
||||||
from dsmview.collectors.logs import LogLine, Severity
|
from dsmview.collectors.logs import LogLine, Severity
|
||||||
from dsmview.ui import theme
|
from dsmview.ui import theme
|
||||||
|
|
@ -16,66 +15,34 @@ _MAX_LINES = 2000
|
||||||
|
|
||||||
|
|
||||||
class LogsTab(Container):
|
class LogsTab(Container):
|
||||||
"""Live log tail with severity and text filters.
|
"""Live log tail with severity filter. The filter keys (a / 1-4) are
|
||||||
|
bound at the App level — pressing them anywhere switches to this tab
|
||||||
Severity filters (a / 1-4) are bound at the App level. Press ``/`` to
|
and applies the filter."""
|
||||||
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._severity: str = "ALL"
|
self._filter: str = "ALL"
|
||||||
self._search: str = ""
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
with Container(classes="panel"):
|
with Container(classes="panel"):
|
||||||
yield Static(
|
yield Static(
|
||||||
"LOGS — [A]LL [1]ERROR [2]WARN [3]SECURITY [4]INFO [/]/ search ESC clear",
|
"LOGS — [A]LL [1]ERROR [2]WARN [3]SECURITY [4]INFO",
|
||||||
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,
|
highlight=False, markup=True, wrap=False, max_lines=_MAX_LINES, id="logs-view"
|
||||||
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._severity = name
|
self._filter = name
|
||||||
self._update_status()
|
self.status_bar.update(f"filter: {name}")
|
||||||
self._rerender()
|
self._rerender()
|
||||||
|
|
||||||
def set_search(self, text: str) -> None:
|
|
||||||
self._search = text.strip().lower()
|
|
||||||
self._update_status()
|
|
||||||
self._rerender()
|
|
||||||
|
|
||||||
def show_search(self) -> None:
|
|
||||||
self.search_input.display = True
|
|
||||||
self.search_input.focus()
|
|
||||||
|
|
||||||
def hide_search(self) -> None:
|
|
||||||
self.search_input.display = False
|
|
||||||
self.search_input.value = ""
|
|
||||||
self.set_search("")
|
|
||||||
self.log_view.focus()
|
|
||||||
|
|
||||||
def on_input_changed(self, event: Input.Changed) -> None:
|
|
||||||
if event.input.id == "logs-search":
|
|
||||||
self.set_search(event.value)
|
|
||||||
|
|
||||||
def push_initial(self, lines: list[LogLine]) -> None:
|
def push_initial(self, lines: list[LogLine]) -> None:
|
||||||
self._buffer.extend(lines)
|
self._buffer.extend(lines)
|
||||||
self._rerender()
|
self._rerender()
|
||||||
|
|
@ -86,12 +53,9 @@ class LogsTab(Container):
|
||||||
self.log_view.write(self._format(line))
|
self.log_view.write(self._format(line))
|
||||||
|
|
||||||
def _matches(self, line: LogLine) -> bool:
|
def _matches(self, line: LogLine) -> bool:
|
||||||
if self._severity != "ALL" and line.severity.value != self._severity:
|
if self._filter == "ALL":
|
||||||
return False
|
|
||||||
if self._search:
|
|
||||||
haystack = (line.raw + " " + line.source).lower()
|
|
||||||
return self._search in haystack
|
|
||||||
return True
|
return True
|
||||||
|
return line.severity.value == self._filter
|
||||||
|
|
||||||
def _format(self, line: LogLine) -> str:
|
def _format(self, line: LogLine) -> str:
|
||||||
color_for = {
|
color_for = {
|
||||||
|
|
@ -102,17 +66,8 @@ class LogsTab(Container):
|
||||||
Severity.OTHER: theme.BORDER_TITLE,
|
Severity.OTHER: theme.BORDER_TITLE,
|
||||||
}
|
}
|
||||||
color = color_for[line.severity]
|
color = color_for[line.severity]
|
||||||
ts = escape(line.timestamp) if line.timestamp else ""
|
src = f"[dim]{line.source:<8}[/]"
|
||||||
ts_part = f"[dim]{ts:<28}[/] " if ts else ""
|
return f"{src} [{color}]{line.raw}[/]"
|
||||||
src = f"[dim]{line.source:<12}[/]"
|
|
||||||
body = escape(line.message or line.raw)
|
|
||||||
return f"{ts_part}{src} [{color}]{body}[/]"
|
|
||||||
|
|
||||||
def _update_status(self) -> None:
|
|
||||||
parts = [f"severity: {self._severity}"]
|
|
||||||
if self._search:
|
|
||||||
parts.append(f"search: \"{self._search}\"")
|
|
||||||
self.status_bar.update(" ".join(parts))
|
|
||||||
|
|
||||||
def _rerender(self) -> None:
|
def _rerender(self) -> None:
|
||||||
self.log_view.clear()
|
self.log_view.clear()
|
||||||
|
|
|
||||||
|
|
@ -1,219 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "dsmview"
|
name = "dsmview"
|
||||||
version = "0.3.1"
|
version = "0.2.0"
|
||||||
description = "TUI SSH monitor for Synology DSM 7.x"
|
description = "TUI SSH monitor for Synology DSM 7.x"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue