Compare commits

..

8 commits
v0.2.1 ... 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 430 additions and 47 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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