Compare commits

..

8 commits
v0.2.2 ... main

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-13 20:09:27 +02:00
github-actions[bot]
cc2f8c6dda chore: refresh formula for v0.2.1 2026-06-13 16:34:12 +00:00
10 changed files with 429 additions and 50 deletions

View file

@ -3,8 +3,8 @@ 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 "ea00a4f40a90d38ffea4f6d27b4be6b9ee71a5f4339f6562713aa42a766a9e77"
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"

View file

@ -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
r refresh now
q quit
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

View file

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

View file

@ -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)

View file

@ -8,7 +8,10 @@ from dsmview.collectors.base import Collector
@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
description: str = ""
@ -65,7 +68,14 @@ class ServicesCollector(Collector[ServicesSnapshot]):
# 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, running=running, description=desc.strip().strip('"')))
out.append(
ServiceInfo(
name=display,
unit=unit,
running=running,
description=desc.strip().strip('"'),
)
)
# Sort: interesting services first, then running ones, then the rest.
def sort_key(s: ServiceInfo) -> tuple:
interesting = 0 if s.name in INTERESTING else 1

View file

@ -18,7 +18,7 @@ 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, LogsTab
from dsmview.ui.tabs import DashboardTab, LogsTab, ServicesTab
# Three refresh tiers — fast for live metrics, medium for storage/services,
@ -37,11 +37,17 @@ class DsmviewApp(App):
Binding("r", "refresh_now", "Refresh"),
Binding("f1", "switch_tab('dashboard')", "Dash"),
Binding("f2", "switch_tab('logs')", "Logs"),
Binding("f3", "switch_tab('services')", "Services"),
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:
@ -67,6 +73,9 @@ class DsmviewApp(App):
with TabPane("F2 Logs", id="logs"):
self.logs_tab = LogsTab()
yield self.logs_tab
with TabPane("F3 Services", id="services"):
self.services_tab = ServicesTab(self.executor)
yield self.services_tab
yield Footer()
def on_mount(self) -> None:
@ -89,11 +98,35 @@ class DsmviewApp(App):
def action_switch_tab(self, tab_id: str) -> None:
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._tick_fast()
self._tick_medium()
@ -133,6 +166,7 @@ class DsmviewApp(App):
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:

View file

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

View file

@ -6,7 +6,7 @@ from typing import Deque
from rich.markup import escape
from textual.app import ComposeResult
from textual.containers import Container
from textual.widgets import RichLog, Static
from textual.widgets import Input, RichLog, Static
from dsmview.collectors.logs import LogLine, Severity
from dsmview.ui import theme
@ -16,34 +16,66 @@ _MAX_LINES = 2000
class LogsTab(Container):
"""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
and applies the filter."""
"""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]ERROR [2]WARN [3]SECURITY [4]INFO",
"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.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"
highlight=False,
markup=True,
wrap=False,
max_lines=_MAX_LINES,
id="logs-view",
)
yield self.log_view
def set_filter(self, name: str) -> None:
self._filter = name
self.status_bar.update(f"filter: {name}")
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()
@ -54,9 +86,12 @@ class LogsTab(Container):
self.log_view.write(self._format(line))
def _matches(self, line: LogLine) -> bool:
if self._filter == "ALL":
return True
return line.severity.value == self._filter
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
def _format(self, line: LogLine) -> str:
color_for = {
@ -67,11 +102,17 @@ class LogsTab(Container):
Severity.OTHER: theme.BORDER_TITLE,
}
color = color_for[line.severity]
# Raw log lines often contain literal brackets like
# `[/path/to/file]` which Rich's markup parser tries to interpret
# as closing tags. Escape so they render as text.
src = f"[dim]{line.source:<8}[/]"
return f"{src} [{color}]{escape(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()

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

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

View file

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