Compare commits
12 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f63599af65 | |||
|
|
a67c5c8214 | ||
| 57d3c97ed4 | |||
|
|
093960ffe4 | ||
| a219c333ff | |||
|
|
7b1bf9ae71 | ||
| ba3c11dc95 | |||
|
|
cc2f8c6dda | ||
| 78b1712fdd | |||
| 367804f681 | |||
| b266bcb605 | |||
|
|
3fe7cff45e |
20 changed files with 890 additions and 476 deletions
7
.github/workflows/release.yml
vendored
7
.github/workflows/release.yml
vendored
|
|
@ -155,11 +155,14 @@ jobs:
|
|||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
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.name "github-actions[bot]"
|
||||
git fetch origin main
|
||||
git checkout main
|
||||
cp Formula/dsmview.rb /tmp/dsmview.rb
|
||||
git checkout -f main
|
||||
git pull --rebase origin main || true
|
||||
cp /tmp/dsmview.rb Formula/dsmview.rb
|
||||
git add Formula/dsmview.rb
|
||||
|
|
|
|||
|
|
@ -3,42 +3,113 @@ class Dsmview < Formula
|
|||
|
||||
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"
|
||||
url "https://github.com/janvete/dsmview/archive/refs/tags/v0.3.1.tar.gz"
|
||||
sha256 "6f0fcda846ee254604b87e06f3eaf20f58cdb148573cc30cb0fc7f31e922d665"
|
||||
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
|
||||
depends_on "rust" => :build
|
||||
|
||||
# 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"
|
||||
resource "PyNaCl" do
|
||||
url "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz"
|
||||
sha256 "018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c"
|
||||
end
|
||||
|
||||
resource "paramiko" do
|
||||
url "https://files.pythonhosted.org/packages/source/p/paramiko/paramiko-3.5.0.tar.gz"
|
||||
sha256 "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
resource "Pygments" do
|
||||
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/source/c/click/click-8.1.7.tar.gz"
|
||||
sha256 "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
resource "plotext" do
|
||||
url "https://files.pythonhosted.org/packages/source/p/plotext/plotext-5.3.2.tar.gz"
|
||||
sha256 "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
url "https://files.pythonhosted.org/packages/c9/d7/f75f397af966fe252d0d34ffd3cae765317fce2134f925f95e7d6725d1ce/plotext-5.3.2.tar.gz"
|
||||
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
|
||||
|
||||
resource "textual-plotext" do
|
||||
url "https://files.pythonhosted.org/packages/source/t/textual-plotext/textual_plotext-0.2.1.tar.gz"
|
||||
sha256 "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
url "https://files.pythonhosted.org/packages/9a/b0/e4e0f38df057db778252db0dd2c08522d7222b8537b6a0181d797b9044bd/textual_plotext-1.0.1.tar.gz"
|
||||
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
|
||||
# PYTHON_RESOURCES_END
|
||||
|
||||
def install
|
||||
virtualenv_install_with_resources
|
||||
|
|
|
|||
48
README.md
48
README.md
|
|
@ -2,11 +2,31 @@
|
|||
|
||||
TUI SSH monitor for Synology DSM 7.x — read-only dashboard inspired by `btop`.
|
||||
|
||||
```
|
||||
brew tap janvete/tools
|
||||
## Installation
|
||||
|
||||
### macOS (Homebrew)
|
||||
|
||||
```bash
|
||||
brew tap janvete/tools https://git.janvete.com/seth/homebrew-tools.git
|
||||
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
|
||||
|
||||
```
|
||||
|
|
@ -29,20 +49,28 @@ 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`.
|
||||
throughput, load average, temperatures, services summary and disk health.
|
||||
- **F2 Logs** — live tail of system, auth and package logs (including
|
||||
Active Backup) with severity filters and text search.
|
||||
- **F3 Services** — `systemctl` service list with up/down status and
|
||||
start/stop/restart actions.
|
||||
|
||||
## Keys
|
||||
|
||||
```
|
||||
F1..F4 switch tab
|
||||
F1..F3 switch tab
|
||||
r refresh now
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
__version__ = "0.1.0"
|
||||
__version__ = "0.3.1"
|
||||
|
|
|
|||
|
|
@ -8,11 +8,15 @@ 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+)")
|
||||
_VENDOR_RE = re.compile(r"Vendor:\s+(?P<vendor>\S+)")
|
||||
_PRODUCT_RE = re.compile(r"Product:\s+(?P<product>.+)")
|
||||
_TEMP_RE = re.compile(
|
||||
r"(?:Temperature_Celsius|Airflow_Temperature_Cel)\s+\S+\s+\d+\s+\d+\s+\d+\s+\S+\s+\S+\s+\S+\s+(\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)
|
||||
_REALLOC_RE = re.compile(r"Reallocated_Sector_Ct\s+.*?\s(\d+)(?:\s|$)", re.MULTILINE)
|
||||
_PENDING_RE = re.compile(r"Current_Pending_Sector\s+.*?\s(\d+)(?:\s|$)", re.MULTILINE)
|
||||
_UNCORR_RE = re.compile(r"Offline_Uncorrectable\s+.*?\s(\d+)(?:\s|$)", re.MULTILINE)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
|
@ -37,20 +41,27 @@ class DisksCollector(Collector[DisksSnapshot]):
|
|||
name = "disks"
|
||||
|
||||
async def collect(self) -> DisksSnapshot:
|
||||
listing = await self.executor.run("ls /dev/sd[a-z] 2>/dev/null")
|
||||
# Synology exposes drives as /dev/sata{N} (newer models) or
|
||||
# /dev/sd{a..z} (older). NVMe cache may be /dev/nvme{N}n1.
|
||||
# List top-level devices only (no partitions like sata1p1).
|
||||
listing = await self.executor.run(
|
||||
"ls -d /dev/sata[0-9] /dev/sata[1-9][0-9] /dev/sd[a-z] /dev/nvme[0-9]n1 2>/dev/null"
|
||||
)
|
||||
devices: List[str] = []
|
||||
for line in listing.stdout.split():
|
||||
line = line.strip()
|
||||
if line.startswith("/dev/sd") and len(line) == 8:
|
||||
if line.startswith("/dev/") and "p" not in line.rsplit("/", 1)[-1]:
|
||||
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)
|
||||
# `-d sat` is needed on Synology because drives are presented via a
|
||||
# SCSI shim. Without it, smartctl reports "SMART unavailable".
|
||||
smart_cmds = [f"smartctl -a -d sat {d} 2>&1 || smartctl -a {d} 2>&1 || true" for d in devices]
|
||||
health_cmds = [f"smartctl -H -d sat {d} 2>&1 || smartctl -H {d} 2>&1 || true" for d in devices]
|
||||
smart_results = await self.executor.gather(smart_cmds, timeout=20.0)
|
||||
health_results = await self.executor.gather(health_cmds, timeout=10.0)
|
||||
|
||||
for device, smart, health in zip(devices, smart_results, health_results):
|
||||
|
|
@ -67,6 +78,12 @@ class DisksCollector(Collector[DisksSnapshot]):
|
|||
m = _MODEL_RE.search(text)
|
||||
if m:
|
||||
info.model = m.group("model").strip()
|
||||
else:
|
||||
# Enterprise drives often report as SCSI (Vendor:/Product:).
|
||||
vendor = _VENDOR_RE.search(text)
|
||||
product = _PRODUCT_RE.search(text)
|
||||
if vendor and product:
|
||||
info.model = f"{vendor.group('vendor').strip()} {product.group('product').strip()}"
|
||||
m = _SIZE_RE.search(text)
|
||||
if m:
|
||||
info.size = m.group("size").strip()
|
||||
|
|
|
|||
|
|
@ -16,10 +16,36 @@ class Severity(str, Enum):
|
|||
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)
|
||||
_ERROR_RE = re.compile(
|
||||
r"\b(error|fail(?:ed|ure)?|critical|EXT4-fs error|I/O error|RAID degraded|"
|
||||
r"panic|oom|crashed|corrupt|cannot|unable)\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)
|
||||
|
|
@ -27,6 +53,8 @@ class LogLine:
|
|||
raw: str
|
||||
source: str
|
||||
severity: Severity = Severity.OTHER
|
||||
timestamp: str = ""
|
||||
message: str = ""
|
||||
|
||||
@classmethod
|
||||
def classify(cls, raw: str, source: str) -> "LogLine":
|
||||
|
|
@ -41,29 +69,47 @@ class LogLine:
|
|||
sev = Severity.WARN
|
||||
elif _INFO_RE.search(raw):
|
||||
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]]):
|
||||
name = "logs"
|
||||
|
||||
SOURCES = (
|
||||
("/var/log/messages", "messages"),
|
||||
("/var/log/auth.log", "auth"),
|
||||
# (path, short_label). Paths are checked at runtime so missing files
|
||||
# simply yield no lines.
|
||||
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]:
|
||||
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)
|
||||
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():
|
||||
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
|
||||
|
||||
async def tail(self, source_index: int = 0) -> AsyncIterator[LogLine]:
|
||||
path, source = self.SOURCES[source_index]
|
||||
async for raw in self.executor.tail(path):
|
||||
src = self.SOURCES[source_index]
|
||||
async for raw in self.executor.tail(src.path):
|
||||
if raw.strip():
|
||||
yield LogLine.classify(raw, source)
|
||||
yield LogLine.classify(raw, src.label)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,22 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
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)
|
||||
class IfaceCounters:
|
||||
|
|
@ -21,6 +32,7 @@ class NetSample:
|
|||
tx_bps: float = 0.0
|
||||
rx_total: int = 0
|
||||
tx_total: int = 0
|
||||
ip: str = ""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
|
@ -28,10 +40,14 @@ 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
|
||||
"""Pick the interface with the most accumulated traffic. Falls
|
||||
back to the first sample if no counters have moved yet."""
|
||||
if not self.samples:
|
||||
return None
|
||||
with_traffic = [s for s in self.samples if (s.rx_total + s.tx_total) > 0]
|
||||
if with_traffic:
|
||||
return max(with_traffic, key=lambda s: s.rx_total + s.tx_total)
|
||||
return self.samples[0]
|
||||
|
||||
|
||||
class NetworkCollector(Collector[NetworkSnapshot]):
|
||||
|
|
@ -42,18 +58,38 @@ class NetworkCollector(Collector[NetworkSnapshot]):
|
|||
self._prev: Dict[str, IfaceCounters] = {}
|
||||
|
||||
async def collect(self) -> NetworkSnapshot:
|
||||
result = await self.executor.run("cat /proc/net/dev")
|
||||
return self._parse(result.stdout)
|
||||
netdev, ipaddr = await self.executor.gather([
|
||||
"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()
|
||||
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:
|
||||
continue
|
||||
name, _, rest = line.partition(":")
|
||||
iface = name.strip()
|
||||
if iface in ("lo",) or iface.startswith("docker") or iface.startswith("veth"):
|
||||
if self._is_hidden(iface):
|
||||
continue
|
||||
fields = rest.split()
|
||||
if len(fields) < 16:
|
||||
|
|
@ -65,12 +101,33 @@ class NetworkCollector(Collector[NetworkSnapshot]):
|
|||
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)
|
||||
sample = NetSample(iface=iface, rx_total=rx, tx_total=tx, ip=ips.get(iface, ""))
|
||||
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)
|
||||
candidates[iface] = sample
|
||||
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
|
||||
|
||||
@staticmethod
|
||||
def _is_hidden(iface: str) -> bool:
|
||||
for prefix in _HIDE_PREFIXES:
|
||||
if iface == prefix or iface.startswith(prefix):
|
||||
return True
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
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:
|
||||
# Human-readable name shown in the UI.
|
||||
name: str
|
||||
# Full systemd unit name, e.g. "pkg-ActiveBackup-logd.service".
|
||||
unit: str = ""
|
||||
running: bool = False
|
||||
status_text: str = ""
|
||||
description: str = ""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
|
@ -21,52 +21,65 @@ class ServicesSnapshot:
|
|||
services: List[ServiceInfo] = field(default_factory=list)
|
||||
|
||||
|
||||
# Names worth surfacing in the compact dashboard view.
|
||||
INTERESTING = {
|
||||
"smbd", "smb", "nmbd", "nginx", "sshd", "nfsd", "ftpd",
|
||||
"avahi-daemon", "crond", "chronyd", "synoindexd", "findhostd",
|
||||
"synocachefs", "synonetd", "synopkg", "synowebapi",
|
||||
"pgsql", "synologdbd",
|
||||
}
|
||||
|
||||
|
||||
class ServicesCollector(Collector[ServicesSnapshot]):
|
||||
"""Lists systemd services with running status.
|
||||
|
||||
Synology DSM 7.x uses systemd. Older `synoservicectl` / `synosystemctl`
|
||||
are absent on recent DSM builds, so we go straight to `systemctl`.
|
||||
"""
|
||||
|
||||
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
|
||||
# --no-legend keeps the table machine-parseable; --plain removes
|
||||
# the unicode tree characters that some systemd versions emit.
|
||||
result = await self.executor.run(
|
||||
"systemctl list-units --type=service --all --no-legend --no-pager --plain 2>/dev/null || true",
|
||||
timeout=10.0,
|
||||
)
|
||||
services = self._parse(result.stdout)
|
||||
return ServicesSnapshot(services=services)
|
||||
|
||||
@staticmethod
|
||||
def _parse_list(text: str) -> List[str]:
|
||||
out: List[str] = []
|
||||
def _parse(text: str) -> List[ServiceInfo]:
|
||||
out: List[ServiceInfo] = []
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
parts = line.split(None, 4)
|
||||
if len(parts) < 4:
|
||||
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
|
||||
unit = parts[0]
|
||||
# systemctl marks failed/loaded/not-found in column 2; we want
|
||||
# the SUB state in column 4 (running/dead/exited/etc).
|
||||
sub_state = parts[3]
|
||||
desc = parts[4] if len(parts) > 4 else ""
|
||||
if not unit.endswith(".service"):
|
||||
continue
|
||||
name = unit[: -len(".service")]
|
||||
# Strip pkg- prefix DSM uses for package-installed services so
|
||||
# smbd shows as smbd, not pkg-smbd.
|
||||
display = name[4:] if name.startswith("pkg-") else name
|
||||
running = sub_state == "running"
|
||||
out.append(
|
||||
ServiceInfo(
|
||||
name=display,
|
||||
unit=unit,
|
||||
running=running,
|
||||
description=desc.strip().strip('"'),
|
||||
)
|
||||
)
|
||||
# Sort: interesting services first, then running ones, then the rest.
|
||||
def sort_key(s: ServiceInfo) -> tuple:
|
||||
interesting = 0 if s.name in INTERESTING else 1
|
||||
running = 0 if s.running else 1
|
||||
return (interesting, running, s.name)
|
||||
out.sort(key=sort_key)
|
||||
return out
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List
|
||||
|
||||
from dsmview.collectors.base import Collector
|
||||
|
||||
_VOLUME_RE = re.compile(r"^/volume\d+$")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class VolumeInfo:
|
||||
|
|
@ -51,7 +54,10 @@ class StorageCollector(Collector[StorageSnapshot]):
|
|||
if len(parts) < 6:
|
||||
continue
|
||||
device, size, used, avail, pct, mount = parts[:6]
|
||||
if not mount.startswith("/volume") and not mount.startswith("/"):
|
||||
# Only show actual storage pools — /volume1, /volume2, etc.
|
||||
# Container Manager bind-mounts (/volume1/@appdata/...) and
|
||||
# system mounts repeat the same usage and just add noise.
|
||||
if not _VOLUME_RE.match(mount):
|
||||
continue
|
||||
if device in ("tmpfs", "devtmpfs", "overlay", "none"):
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -67,7 +67,8 @@ class SystemCollector(Collector[SystemSnapshot]):
|
|||
"cat /proc/cpuinfo",
|
||||
"cat /proc/uptime",
|
||||
"cat /proc/sys/kernel/hostname",
|
||||
"cat /etc/synoinfo.conf 2>/dev/null | grep -E '^(unique|productversion|buildnumber|upnpmodelname)='",
|
||||
"cat /etc/VERSION 2>/dev/null",
|
||||
"cat /etc/synoinfo.conf 2>/dev/null | grep -E '^(unique|upnpmodelname)='",
|
||||
"synotemperature 2>/dev/null || true",
|
||||
]
|
||||
results = await self.executor.gather(cmds)
|
||||
|
|
@ -78,8 +79,9 @@ class SystemCollector(Collector[SystemSnapshot]):
|
|||
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)
|
||||
self._parse_version(results[6].stdout, snap)
|
||||
self._parse_synoinfo(results[7].stdout, snap)
|
||||
self._parse_temp(results[8].stdout, snap)
|
||||
return snap
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -154,6 +156,24 @@ class SystemCollector(Collector[SystemSnapshot]):
|
|||
except ValueError:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _parse_version(text: str, snap: SystemSnapshot) -> None:
|
||||
# /etc/VERSION is the authoritative source for productversion +
|
||||
# buildnumber on DSM 7.x. synoinfo.conf may or may not have it.
|
||||
product = ""
|
||||
build = ""
|
||||
for line in text.splitlines():
|
||||
key, _, value = line.partition("=")
|
||||
value = value.strip().strip('"')
|
||||
if key == "productversion":
|
||||
product = value
|
||||
elif key == "buildnumber":
|
||||
build = value
|
||||
if product and build:
|
||||
snap.dsm_version = f"{product}-{build}"
|
||||
elif product:
|
||||
snap.dsm_version = product
|
||||
|
||||
@staticmethod
|
||||
def _parse_synoinfo(text: str, snap: SystemSnapshot) -> None:
|
||||
for line in text.splitlines():
|
||||
|
|
@ -161,10 +181,6 @@ class SystemCollector(Collector[SystemSnapshot]):
|
|||
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:
|
||||
|
|
|
|||
|
|
@ -1,15 +1,10 @@
|
|||
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 (
|
||||
|
|
@ -23,37 +18,14 @@ from dsmview.collectors import (
|
|||
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
|
||||
from dsmview.ui.tabs import DashboardTab, 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)
|
||||
# Three refresh tiers — fast for live metrics, medium for storage/services,
|
||||
# slow for SMART (smartctl is heavy and runs per disk).
|
||||
REFRESH_FAST = 1.0
|
||||
REFRESH_MED = 5.0
|
||||
REFRESH_SLOW = 30.0
|
||||
|
||||
|
||||
class DsmviewApp(App):
|
||||
|
|
@ -66,13 +38,22 @@ class DsmviewApp(App):
|
|||
Binding("f1", "switch_tab('dashboard')", "Dash"),
|
||||
Binding("f2", "switch_tab('logs')", "Logs"),
|
||||
Binding("f3", "switch_tab('services')", "Services"),
|
||||
Binding("f4", "switch_tab('disks')", "Disks"),
|
||||
Binding("a", "filter('ALL')", "All", show=False),
|
||||
Binding("1", "filter('ERROR')", "Err", show=False),
|
||||
Binding("2", "filter('WARN')", "Warn", show=False),
|
||||
Binding("3", "filter('SECURITY')", "Sec", 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:
|
||||
super().__init__()
|
||||
self.connection = connection
|
||||
self.executor = Executor(connection, max_workers=6)
|
||||
self.executor = Executor(connection, max_workers=8)
|
||||
self.system = SystemCollector(self.executor)
|
||||
self.storage = StorageCollector(self.executor)
|
||||
self.disks = DisksCollector(self.executor)
|
||||
|
|
@ -80,7 +61,6 @@ class DsmviewApp(App):
|
|||
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:
|
||||
|
|
@ -94,16 +74,15 @@ class DsmviewApp(App):
|
|||
self.logs_tab = LogsTab()
|
||||
yield self.logs_tab
|
||||
with TabPane("F3 Services", id="services"):
|
||||
self.services_tab = ServicesTab(on_action=self._handle_service_action)
|
||||
self.services_tab = ServicesTab(self.executor)
|
||||
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(REFRESH_FAST, self._tick_fast)
|
||||
self.set_interval(REFRESH_MED, self._tick_medium)
|
||||
self.set_interval(REFRESH_SLOW, self._tick_slow)
|
||||
self.set_interval(1.0, self._update_topbar)
|
||||
self.action_refresh_now()
|
||||
self._start_log_tails()
|
||||
|
|
@ -118,44 +97,88 @@ class DsmviewApp(App):
|
|||
self.connection.close()
|
||||
|
||||
def action_switch_tab(self, tab_id: str) -> None:
|
||||
tabbed = self.query_one(TabbedContent)
|
||||
tabbed.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:
|
||||
self.query_one(TabbedContent).active = "logs"
|
||||
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:
|
||||
self.run_worker(self._refresh_all(), exclusive=True, group="refresh")
|
||||
self._tick_fast()
|
||||
self._tick_medium()
|
||||
self._tick_slow()
|
||||
|
||||
async def _refresh_all(self) -> None:
|
||||
async with self._refresh_lock:
|
||||
def _tick_fast(self) -> None:
|
||||
self.run_worker(self._refresh_fast(), exclusive=True, group="refresh-fast")
|
||||
|
||||
def _tick_medium(self) -> None:
|
||||
self.run_worker(self._refresh_medium(), exclusive=True, group="refresh-med")
|
||||
|
||||
def _tick_slow(self) -> None:
|
||||
self.run_worker(self._refresh_slow(), exclusive=True, group="refresh-slow")
|
||||
|
||||
async def _refresh_fast(self) -> None:
|
||||
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
|
||||
system_snap, net_snap = await asyncio.gather(
|
||||
self.system.collect(),
|
||||
self.network.collect(),
|
||||
)
|
||||
except Exception as e:
|
||||
self.notify(f"Refresh error: {e}", severity="error", timeout=5)
|
||||
self.notify(f"fast refresh error: {e}", severity="error", timeout=4)
|
||||
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)
|
||||
if self._dsm_label == "—":
|
||||
self._dsm_label = self._make_dsm_label(system_snap)
|
||||
|
||||
async def _refresh_medium(self) -> None:
|
||||
try:
|
||||
storage_snap, services_snap = await asyncio.gather(
|
||||
self.storage.collect(),
|
||||
self.services.collect(),
|
||||
)
|
||||
except Exception as e:
|
||||
self.notify(f"storage/services error: {e}", severity="warning", timeout=4)
|
||||
return
|
||||
self.dashboard.update_storage(storage_snap)
|
||||
self.dashboard.update_services(services_snap.services)
|
||||
self.services_tab.update(services_snap.services)
|
||||
|
||||
async def _refresh_slow(self) -> None:
|
||||
try:
|
||||
disks_snap = await self.disks.collect()
|
||||
except Exception as e:
|
||||
self.notify(f"disks error: {e}", severity="warning", timeout=4)
|
||||
return
|
||||
self.dashboard.update_disks(disks_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):
|
||||
for i in range(len(self.logs.SOURCES)):
|
||||
w = self.run_worker(self._tail_logs(i), group=f"tail-{i}", exclusive=False)
|
||||
self._tail_workers.append(w)
|
||||
|
||||
|
|
@ -164,7 +187,7 @@ class DsmviewApp(App):
|
|||
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)
|
||||
self.notify(f"log fetch error: {e}", severity="warning", timeout=4)
|
||||
|
||||
async def _tail_logs(self, source_index: int) -> None:
|
||||
try:
|
||||
|
|
@ -173,25 +196,6 @@ class DsmviewApp(App):
|
|||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
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"]
|
||||
__all__ = ["DashboardTab", "LogsTab", "ServicesTab"]
|
||||
|
|
|
|||
|
|
@ -1,14 +1,18 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Container, Horizontal, Vertical
|
||||
from textual.widgets import Static
|
||||
from typing import Iterable
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Container, Horizontal, Vertical, VerticalScroll
|
||||
from textual.widgets import DataTable, Static
|
||||
|
||||
from dsmview.collectors.disks import DisksSnapshot
|
||||
from dsmview.collectors.network import NetworkSnapshot
|
||||
from dsmview.collectors.services import ServiceInfo
|
||||
from dsmview.collectors.storage import StorageSnapshot
|
||||
from dsmview.collectors.system import SystemSnapshot
|
||||
from dsmview.ui import theme
|
||||
from dsmview.ui.widgets import BrailleGraph, MeterBar
|
||||
from dsmview.ui.widgets import BrailleGraph
|
||||
|
||||
|
||||
def _fmt_bytes_per_sec(b: float) -> str:
|
||||
|
|
@ -30,87 +34,163 @@ def _fmt_uptime(s: float) -> str:
|
|||
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
|
||||
def _inline_bar(percent: float, width: int, color: str) -> str:
|
||||
percent = max(0.0, min(100.0, percent))
|
||||
filled = int(round(width * percent / 100.0))
|
||||
return f"[{color}]" + "█" * filled + "[/]" + "░" * (width - filled)
|
||||
|
||||
with Vertical(id="storage-panel", classes="panel"):
|
||||
|
||||
class DashboardTab(VerticalScroll):
|
||||
"""Single-screen dashboard packing system / storage / network /
|
||||
services / disks into one vertically-flowing view."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
DashboardTab {
|
||||
padding: 0 1;
|
||||
}
|
||||
"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
# System (CPU + MEM compact, inline summary + braille graph)
|
||||
with Container(classes="panel"):
|
||||
yield Static("SYSTEM", classes="panel-title")
|
||||
self.cpu_line = Static("")
|
||||
yield self.cpu_line
|
||||
self.cpu_graph = BrailleGraph(color="red", y_max=100.0)
|
||||
self.cpu_graph.styles.height = 4
|
||||
yield self.cpu_graph
|
||||
self.mem_line = Static("")
|
||||
yield self.mem_line
|
||||
|
||||
with Container(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"):
|
||||
with Container(classes="panel"):
|
||||
yield Static("NETWORK", classes="panel-title")
|
||||
self.net_summary = Static("")
|
||||
yield self.net_summary
|
||||
with Horizontal():
|
||||
with Horizontal(id="net-graphs"):
|
||||
self.net_rx_graph = BrailleGraph(color="magenta")
|
||||
self.net_rx_graph.styles.height = 4
|
||||
self.net_tx_graph = BrailleGraph(color="blue")
|
||||
self.net_tx_graph.styles.height = 4
|
||||
yield self.net_rx_graph
|
||||
yield self.net_tx_graph
|
||||
|
||||
with Container(classes="panel"):
|
||||
yield Static("SERVICES", classes="panel-title")
|
||||
self.services_body = Static("")
|
||||
yield self.services_body
|
||||
|
||||
with Container(classes="panel"):
|
||||
yield Static("DISKS", classes="panel-title")
|
||||
self.disks_table = DataTable(zebra_stripes=False, show_cursor=False)
|
||||
self.disks_table.add_columns("DISK", "MODEL", "SIZE", "TEMP", "HEALTH", "REALLOC", "PENDING")
|
||||
yield self.disks_table
|
||||
|
||||
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}"
|
||||
bar = _inline_bar(s.cpu_percent, 30, theme.CPU_BAR)
|
||||
temp = f" [grey70]temp[/] {s.temperature_c:.0f}°C" if s.temperature_c is not None else ""
|
||||
self.cpu_line.update(
|
||||
f"[bold]CPU[/] {s.cpu_percent:5.1f}% {bar} "
|
||||
f"[grey70]load[/] {s.load_1:.2f}/{s.load_5:.2f}/{s.load_15:.2f} "
|
||||
f"[grey70]{s.cpu_count}c[/] [grey70]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"
|
||||
self.cpu_graph.push(s.cpu_percent)
|
||||
|
||||
mem_bar = _inline_bar(s.mem_percent, 30, theme.MEM_BAR)
|
||||
used_mib = s.mem_used_kb / 1024
|
||||
total_mib = s.mem_total_kb / 1024
|
||||
cached_mib = s.mem_cached_kb / 1024
|
||||
self.mem_line.update(
|
||||
f"[bold]MEM[/] {s.mem_percent:5.1f}% {mem_bar} "
|
||||
f"[grey70]used[/] {used_mib:6.0f} / {total_mib:6.0f} MiB "
|
||||
f"[grey70]cached[/] {cached_mib:5.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)
|
||||
bar = _inline_bar(v.percent, 24, color)
|
||||
lines.append(
|
||||
f"{v.mount:<14} {v.used:>6}/{v.size:>6} {bar} {v.percent:5.1f}%"
|
||||
f" {v.mount:<14} {v.used:>7} / {v.size:>7} {bar} {v.percent:5.1f}%"
|
||||
)
|
||||
if not lines:
|
||||
lines.append("(no volumes detected)")
|
||||
lines.append(" (no volumes detected)")
|
||||
if s.raids:
|
||||
lines.append("")
|
||||
for r in s.raids:
|
||||
lines.append(f" RAID {r.name} {r.level} {r.state}")
|
||||
lines.append(f" [grey70]RAID[/] {r.name} {r.level:<8} {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)")
|
||||
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)}[/]"
|
||||
# One line per interface so multi-NIC setups (LACP, OvS, Tailscale)
|
||||
# are visible. The primary is highlighted because the graph
|
||||
# underneath tracks it.
|
||||
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
|
||||
|
||||
def update_services(self, services: Iterable[ServiceInfo]) -> None:
|
||||
items = list(services)
|
||||
if not items:
|
||||
self.services_body.update(" (no services detected)")
|
||||
return
|
||||
total = len(items)
|
||||
running = sum(1 for s in items if s.running)
|
||||
# The collector already sorted interesting+running first, so the
|
||||
# head of the list is the most useful.
|
||||
shown = items[: self.SERVICES_LIMIT]
|
||||
max_name = min(20, max((len(s.name) for s in shown), default=8))
|
||||
cells = []
|
||||
for s in shown:
|
||||
icon = f"[{theme.OK}]✅[/]" if s.running else f"[{theme.STOPPED}]⛔[/]"
|
||||
name = (s.name[:max_name]).ljust(max_name)
|
||||
cells.append(f"{icon} {name}")
|
||||
per_row = 3
|
||||
rows = [f" [dim]({running}/{total} running, showing top {len(shown)})[/]"]
|
||||
for i in range(0, len(cells), per_row):
|
||||
rows.append(" " + " ".join(cells[i:i + per_row]))
|
||||
self.services_body.update("\n".join(rows))
|
||||
|
||||
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}[/]"
|
||||
model = (d.model or "—")[:30]
|
||||
self.disks_table.add_row(
|
||||
d.device.removeprefix("/dev/"), model, 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 "—",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,49 +0,0 @@
|
|||
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))
|
||||
|
|
@ -3,10 +3,10 @@ from __future__ import annotations
|
|||
from collections import deque
|
||||
from typing import Deque
|
||||
|
||||
from rich.markup import escape
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Container, Horizontal
|
||||
from textual.widgets import RichLog, Static
|
||||
from textual.containers import Container
|
||||
from textual.widgets import Input, RichLog, Static
|
||||
|
||||
from dsmview.collectors.logs import LogLine, Severity
|
||||
from dsmview.ui import theme
|
||||
|
|
@ -16,32 +16,66 @@ _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),
|
||||
]
|
||||
"""Live log tail with severity and text filters.
|
||||
|
||||
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:
|
||||
super().__init__()
|
||||
self._buffer: Deque[LogLine] = deque(maxlen=_MAX_LINES)
|
||||
self._filter: str = "ALL"
|
||||
self._severity: str = "ALL"
|
||||
self._search: str = ""
|
||||
|
||||
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")
|
||||
yield Static(
|
||||
"LOGS — [A]LL [1]ERROR [2]WARN [3]SECURITY [4]INFO [/]/ search ESC clear",
|
||||
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")
|
||||
self.search_input = Input(
|
||||
placeholder="search logs...",
|
||||
id="logs-search",
|
||||
)
|
||||
self.search_input.display = False
|
||||
yield self.search_input
|
||||
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}")
|
||||
def set_filter(self, name: str) -> None:
|
||||
self._severity = name
|
||||
self._update_status()
|
||||
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:
|
||||
self._buffer.extend(lines)
|
||||
self._rerender()
|
||||
|
|
@ -52,9 +86,12 @@ class LogsTab(Container):
|
|||
self.log_view.write(self._format(line))
|
||||
|
||||
def _matches(self, line: LogLine) -> bool:
|
||||
if self._filter == "ALL":
|
||||
if self._severity != "ALL" and line.severity.value != self._severity:
|
||||
return False
|
||||
if self._search:
|
||||
haystack = (line.raw + " " + line.source).lower()
|
||||
return self._search in haystack
|
||||
return True
|
||||
return line.severity.value == self._filter
|
||||
|
||||
def _format(self, line: LogLine) -> str:
|
||||
color_for = {
|
||||
|
|
@ -65,8 +102,17 @@ class LogsTab(Container):
|
|||
Severity.OTHER: theme.BORDER_TITLE,
|
||||
}
|
||||
color = color_for[line.severity]
|
||||
src = f"[dim]{line.source:<8}[/]"
|
||||
return f"{src} [{color}]{line.raw}[/]"
|
||||
ts = escape(line.timestamp) if line.timestamp else ""
|
||||
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:
|
||||
self.log_view.clear()
|
||||
|
|
|
|||
|
|
@ -1,45 +1,219 @@
|
|||
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 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.ui.widgets import ServiceList
|
||||
from dsmview.ssh.executor import Executor
|
||||
from dsmview.ui import theme
|
||||
|
||||
|
||||
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),
|
||||
]
|
||||
class ConfirmActionScreen(ModalScreen[bool]):
|
||||
"""Tiny modal asking the user to confirm a destructive service action."""
|
||||
|
||||
def __init__(self, on_action: Callable[[str, str], None]) -> None:
|
||||
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._on_action = on_action
|
||||
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 — [enter] detail [r] restart [s] stop [S] start",
|
||||
"SERVICES — ↑/↓ select, then Start / Stop / Restart",
|
||||
classes="panel-title",
|
||||
id="services-header",
|
||||
)
|
||||
self.table = ServiceList()
|
||||
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_services(self, services: Iterable[ServiceInfo]) -> None:
|
||||
self.table.update_services(services)
|
||||
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 = ""
|
||||
|
||||
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:
|
||||
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
|
||||
row_key = self.table.coordinate_to_cell_key((row, 0)).row_key
|
||||
name = row_key.value if row_key else None
|
||||
if not name:
|
||||
|
||||
def on_confirm(confirmed: bool) -> None:
|
||||
if not confirmed:
|
||||
return
|
||||
self._on_action(action, name)
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -27,11 +27,6 @@ Screen {{
|
|||
color: {BORDER_TITLE};
|
||||
}}
|
||||
|
||||
Header {{
|
||||
background: {BG};
|
||||
color: {BORDER_TITLE};
|
||||
}}
|
||||
|
||||
Footer {{
|
||||
background: {BG};
|
||||
color: {BORDER_TITLE};
|
||||
|
|
@ -68,52 +63,24 @@ Tab.-active {{
|
|||
background: {BG};
|
||||
color: {BORDER_TITLE};
|
||||
padding: 0 1;
|
||||
margin-bottom: 1;
|
||||
height: auto;
|
||||
}}
|
||||
|
||||
.panel > .panel-title {{
|
||||
.panel > Static.panel-title {{
|
||||
color: {BORDER_TITLE};
|
||||
text-style: bold;
|
||||
height: 1;
|
||||
}}
|
||||
|
||||
.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};
|
||||
#net-graphs {{
|
||||
height: 5;
|
||||
}}
|
||||
|
||||
DataTable {{
|
||||
background: {BG};
|
||||
color: {BORDER_TITLE};
|
||||
height: auto;
|
||||
}}
|
||||
|
||||
DataTable > .datatable--header {{
|
||||
|
|
@ -121,43 +88,4 @@ DataTable > .datatable--header {{
|
|||
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;
|
||||
}}
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
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"]
|
||||
__all__ = ["BrailleGraph", "MeterBar"]
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
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)
|
||||
|
|
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||
|
||||
[project]
|
||||
name = "dsmview"
|
||||
version = "0.1.0"
|
||||
version = "0.3.1"
|
||||
description = "TUI SSH monitor for Synology DSM 7.x"
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue