Compare commits

..

1 commit
main ... v0.2.2

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

View file

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

View file

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

View file

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

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

View file

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

View file

@ -6,7 +6,7 @@ from typing import Deque
from rich.markup import escape from rich.markup import escape
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.containers import Container from textual.containers import Container
from textual.widgets import Input, RichLog, Static from textual.widgets import RichLog, Static
from dsmview.collectors.logs import LogLine, Severity from dsmview.collectors.logs import LogLine, Severity
from dsmview.ui import theme from dsmview.ui import theme
@ -16,66 +16,34 @@ _MAX_LINES = 2000
class LogsTab(Container): class LogsTab(Container):
"""Live log tail with severity and text filters. """Live log tail with severity filter. The filter keys (a / 1-4) are
bound at the App level — pressing them anywhere switches to this tab
Severity filters (a / 1-4) are bound at the App level. Press ``/`` to and applies the filter."""
start a text search and ``escape`` to clear it.
"""
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self._buffer: Deque[LogLine] = deque(maxlen=_MAX_LINES) self._buffer: Deque[LogLine] = deque(maxlen=_MAX_LINES)
self._severity: str = "ALL" self._filter: str = "ALL"
self._search: str = ""
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with Container(classes="panel"): with Container(classes="panel"):
yield Static( yield Static(
"LOGS — [A]LL [1]ERROR [2]WARN [3]SECURITY [4]INFO [/]/ search ESC clear", "LOGS — [A]LL [1]ERROR [2]WARN [3]SECURITY [4]INFO",
classes="panel-title", classes="panel-title",
id="logs-header", id="logs-header",
) )
self.status_bar = Static("filter: ALL", id="logs-status") self.status_bar = Static("filter: ALL", id="logs-status")
yield self.status_bar yield self.status_bar
self.search_input = Input(
placeholder="search logs...",
id="logs-search",
)
self.search_input.display = False
yield self.search_input
self.log_view = RichLog( self.log_view = RichLog(
highlight=False, highlight=False, markup=True, wrap=False, max_lines=_MAX_LINES, id="logs-view"
markup=True,
wrap=False,
max_lines=_MAX_LINES,
id="logs-view",
) )
yield self.log_view yield self.log_view
def set_filter(self, name: str) -> None: def set_filter(self, name: str) -> None:
self._severity = name self._filter = name
self._update_status() self.status_bar.update(f"filter: {name}")
self._rerender() self._rerender()
def set_search(self, text: str) -> None:
self._search = text.strip().lower()
self._update_status()
self._rerender()
def show_search(self) -> None:
self.search_input.display = True
self.search_input.focus()
def hide_search(self) -> None:
self.search_input.display = False
self.search_input.value = ""
self.set_search("")
self.log_view.focus()
def on_input_changed(self, event: Input.Changed) -> None:
if event.input.id == "logs-search":
self.set_search(event.value)
def push_initial(self, lines: list[LogLine]) -> None: def push_initial(self, lines: list[LogLine]) -> None:
self._buffer.extend(lines) self._buffer.extend(lines)
self._rerender() self._rerender()
@ -86,12 +54,9 @@ class LogsTab(Container):
self.log_view.write(self._format(line)) self.log_view.write(self._format(line))
def _matches(self, line: LogLine) -> bool: def _matches(self, line: LogLine) -> bool:
if self._severity != "ALL" and line.severity.value != self._severity: if self._filter == "ALL":
return False return True
if self._search: return line.severity.value == self._filter
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 = {
@ -102,17 +67,11 @@ class LogsTab(Container):
Severity.OTHER: theme.BORDER_TITLE, Severity.OTHER: theme.BORDER_TITLE,
} }
color = color_for[line.severity] color = color_for[line.severity]
ts = escape(line.timestamp) if line.timestamp else "" # Raw log lines often contain literal brackets like
ts_part = f"[dim]{ts:<28}[/] " if ts else "" # `[/path/to/file]` which Rich's markup parser tries to interpret
src = f"[dim]{line.source:<12}[/]" # as closing tags. Escape so they render as text.
body = escape(line.message or line.raw) src = f"[dim]{line.source:<8}[/]"
return f"{ts_part}{src} [{color}]{body}[/]" return f"{src} [{color}]{escape(line.raw)}[/]"
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()

View file

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

View file

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "dsmview" name = "dsmview"
version = "0.3.1" version = "0.2.2"
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" }