Compare commits
No commits in common. "main" and "v0.2.1" have entirely different histories.
10 changed files with 47 additions and 430 deletions
|
|
@ -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.3.1.tar.gz"
|
||||
sha256 "6f0fcda846ee254604b87e06f3eaf20f58cdb148573cc30cb0fc7f31e922d665"
|
||||
url "https://github.com/janvete/dsmview/archive/refs/tags/v0.1.0.tar.gz"
|
||||
sha256 "ea00a4f40a90d38ffea4f6d27b4be6b9ee71a5f4339f6562713aa42a766a9e77"
|
||||
license "MIT"
|
||||
head "https://github.com/janvete/dsmview.git", branch: "main"
|
||||
|
||||
|
|
|
|||
48
README.md
48
README.md
|
|
@ -2,31 +2,11 @@
|
|||
|
||||
TUI SSH monitor for Synology DSM 7.x — read-only dashboard inspired by `btop`.
|
||||
|
||||
## Installation
|
||||
|
||||
### macOS (Homebrew)
|
||||
|
||||
```bash
|
||||
brew tap janvete/tools https://git.janvete.com/seth/homebrew-tools.git
|
||||
```
|
||||
brew tap janvete/tools
|
||||
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
|
||||
|
||||
```
|
||||
|
|
@ -49,28 +29,20 @@ service — and that requires explicit confirmation in the UI.
|
|||
## Tabs
|
||||
|
||||
- **F1 Dashboard** — CPU history graph, memory, storage pools, network
|
||||
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.
|
||||
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`.
|
||||
|
||||
## Keys
|
||||
|
||||
```
|
||||
F1..F3 switch tab
|
||||
F1..F4 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.3.1"
|
||||
__version__ = "0.2.1"
|
||||
|
|
|
|||
|
|
@ -16,36 +16,10 @@ 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|"
|
||||
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
|
||||
_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)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
|
@ -53,8 +27,6 @@ class LogLine:
|
|||
raw: str
|
||||
source: str
|
||||
severity: Severity = Severity.OTHER
|
||||
timestamp: str = ""
|
||||
message: str = ""
|
||||
|
||||
@classmethod
|
||||
def classify(cls, raw: str, source: str) -> "LogLine":
|
||||
|
|
@ -69,47 +41,29 @@ class LogLine:
|
|||
sev = Severity.WARN
|
||||
elif _INFO_RE.search(raw):
|
||||
sev = Severity.INFO
|
||||
|
||||
# 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,
|
||||
)
|
||||
return cls(raw=raw, source=source, severity=sev)
|
||||
|
||||
|
||||
class LogCollector(Collector[List[LogLine]]):
|
||||
name = "logs"
|
||||
|
||||
# (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"),
|
||||
SOURCES = (
|
||||
("/var/log/messages", "messages"),
|
||||
("/var/log/auth.log", "auth"),
|
||||
)
|
||||
|
||||
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)
|
||||
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():
|
||||
if raw.strip():
|
||||
lines.append(LogLine.classify(raw, src.label))
|
||||
# Newest last so the RichLog scrolls to the bottom naturally.
|
||||
lines.append(LogLine.classify(raw, source))
|
||||
return lines
|
||||
|
||||
async def tail(self, source_index: int = 0) -> AsyncIterator[LogLine]:
|
||||
src = self.SOURCES[source_index]
|
||||
async for raw in self.executor.tail(src.path):
|
||||
path, source = self.SOURCES[source_index]
|
||||
async for raw in self.executor.tail(path):
|
||||
if raw.strip():
|
||||
yield LogLine.classify(raw, src.label)
|
||||
yield LogLine.classify(raw, source)
|
||||
|
|
|
|||
|
|
@ -8,10 +8,7 @@ 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 = ""
|
||||
|
||||
|
|
@ -68,14 +65,7 @@ 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,
|
||||
unit=unit,
|
||||
running=running,
|
||||
description=desc.strip().strip('"'),
|
||||
)
|
||||
)
|
||||
out.append(ServiceInfo(name=display, 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
|
||||
|
|
|
|||
|
|
@ -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, ServicesTab
|
||||
from dsmview.ui.tabs import DashboardTab, LogsTab
|
||||
|
||||
|
||||
# Three refresh tiers — fast for live metrics, medium for storage/services,
|
||||
|
|
@ -37,17 +37,11 @@ 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:
|
||||
|
|
@ -73,9 +67,6 @@ 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:
|
||||
|
|
@ -98,35 +89,11 @@ 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()
|
||||
|
|
@ -166,7 +133,6 @@ 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:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
from dsmview.ui.tabs.dashboard import DashboardTab
|
||||
from dsmview.ui.tabs.logs import LogsTab
|
||||
from dsmview.ui.tabs.services import ServicesTab
|
||||
|
||||
__all__ = ["DashboardTab", "LogsTab", "ServicesTab"]
|
||||
__all__ = ["DashboardTab", "LogsTab"]
|
||||
|
|
|
|||
|
|
@ -3,10 +3,9 @@ from __future__ import annotations
|
|||
from collections import deque
|
||||
from typing import Deque
|
||||
|
||||
from rich.markup import escape
|
||||
from textual.app import ComposeResult
|
||||
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.ui import theme
|
||||
|
|
@ -16,66 +15,34 @@ _MAX_LINES = 2000
|
|||
|
||||
|
||||
class LogsTab(Container):
|
||||
"""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.
|
||||
"""
|
||||
"""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."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._buffer: Deque[LogLine] = deque(maxlen=_MAX_LINES)
|
||||
self._severity: str = "ALL"
|
||||
self._search: str = ""
|
||||
self._filter: str = "ALL"
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Container(classes="panel"):
|
||||
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",
|
||||
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._severity = name
|
||||
self._update_status()
|
||||
self._filter = name
|
||||
self.status_bar.update(f"filter: {name}")
|
||||
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()
|
||||
|
|
@ -86,12 +53,9 @@ class LogsTab(Container):
|
|||
self.log_view.write(self._format(line))
|
||||
|
||||
def _matches(self, line: LogLine) -> bool:
|
||||
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
|
||||
if self._filter == "ALL":
|
||||
return True
|
||||
return line.severity.value == self._filter
|
||||
|
||||
def _format(self, line: LogLine) -> str:
|
||||
color_for = {
|
||||
|
|
@ -102,17 +66,8 @@ class LogsTab(Container):
|
|||
Severity.OTHER: theme.BORDER_TITLE,
|
||||
}
|
||||
color = color_for[line.severity]
|
||||
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))
|
||||
src = f"[dim]{line.source:<8}[/]"
|
||||
return f"{src} [{color}]{line.raw}[/]"
|
||||
|
||||
def _rerender(self) -> None:
|
||||
self.log_view.clear()
|
||||
|
|
|
|||
|
|
@ -1,219 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Container, Horizontal, VerticalScroll
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Button, DataTable, Static
|
||||
|
||||
from dsmview.collectors.services import ServiceInfo
|
||||
from dsmview.ssh.executor import Executor
|
||||
from dsmview.ui import theme
|
||||
|
||||
|
||||
class ConfirmActionScreen(ModalScreen[bool]):
|
||||
"""Tiny modal asking the user to confirm a destructive service action."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
ConfirmActionScreen {
|
||||
align: center middle;
|
||||
}
|
||||
ConfirmActionScreen > Container {
|
||||
width: 60;
|
||||
height: auto;
|
||||
border: round $border;
|
||||
background: $surface;
|
||||
padding: 1 2;
|
||||
}
|
||||
ConfirmActionScreen #confirm-title {
|
||||
text-style: bold;
|
||||
height: auto;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
ConfirmActionScreen #confirm-buttons {
|
||||
height: auto;
|
||||
align: center middle;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, action: str, service: str) -> None:
|
||||
super().__init__()
|
||||
self.action = action
|
||||
self.service = service
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Container():
|
||||
yield Static(
|
||||
f"Confirm {self.action} of [bold]{self.service}[/]?",
|
||||
id="confirm-title",
|
||||
)
|
||||
with Horizontal(id="confirm-buttons"):
|
||||
yield Button("Yes", id="confirm-yes", variant="error")
|
||||
yield Button("No", id="confirm-no", variant="primary")
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
self.dismiss(event.button.id == "confirm-yes")
|
||||
|
||||
|
||||
class ServicesTab(VerticalScroll):
|
||||
"""Interactive service list with visible start/stop/restart controls."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
ServicesTab {
|
||||
padding: 0 1;
|
||||
}
|
||||
ServicesTab #services-table {
|
||||
height: 1fr;
|
||||
min-height: 10;
|
||||
}
|
||||
ServicesTab #services-buttons {
|
||||
height: auto;
|
||||
margin-top: 1;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, executor: Executor) -> None:
|
||||
super().__init__()
|
||||
self.executor = executor
|
||||
self._services: list[ServiceInfo] = []
|
||||
self._last_cursor_row: int = 0
|
||||
self._last_selected_unit: str = ""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Container(classes="panel"):
|
||||
yield Static(
|
||||
"SERVICES — ↑/↓ select, then Start / Stop / Restart",
|
||||
classes="panel-title",
|
||||
id="services-header",
|
||||
)
|
||||
self.status_bar = Static("loading...", id="services-status")
|
||||
yield self.status_bar
|
||||
self.table = DataTable(
|
||||
zebra_stripes=True,
|
||||
show_cursor=True,
|
||||
cursor_type="row",
|
||||
id="services-table",
|
||||
)
|
||||
self.table.add_columns("SERVICE", "STATUS", "DESCRIPTION")
|
||||
yield self.table
|
||||
with Horizontal(id="services-buttons"):
|
||||
yield Button("Start", id="btn-start", variant="success")
|
||||
yield Button("Stop", id="btn-stop", variant="error")
|
||||
yield Button("Restart", id="btn-restart", variant="warning")
|
||||
|
||||
def update(self, services: list[ServiceInfo]) -> None:
|
||||
# Remember what was selected so we can restore the cursor after the
|
||||
# periodic refresh re-renders the table.
|
||||
if self.table.row_count:
|
||||
coord = self.table.cursor_coordinate
|
||||
self._last_cursor_row = coord.row
|
||||
if 0 <= coord.row < len(self._services):
|
||||
self._last_selected_unit = self._services[coord.row].unit
|
||||
else:
|
||||
self._last_selected_unit = ""
|
||||
|
||||
self._services = services
|
||||
self.table.clear()
|
||||
running = sum(1 for s in services if s.running)
|
||||
self.status_bar.update(
|
||||
f"[dim]{running}/{len(services)} running[/]"
|
||||
)
|
||||
for s in services:
|
||||
status = (
|
||||
f"[{theme.OK}]running[/]" if s.running
|
||||
else f"[{theme.STOPPED}]stopped[/]"
|
||||
)
|
||||
self.table.add_row(s.name, status, s.description)
|
||||
|
||||
# Restore cursor: prefer the previously selected unit, otherwise the
|
||||
# previously selected row index.
|
||||
new_row = -1
|
||||
if self._last_selected_unit:
|
||||
for i, s in enumerate(services):
|
||||
if s.unit == self._last_selected_unit:
|
||||
new_row = i
|
||||
break
|
||||
if new_row < 0 and 0 <= self._last_cursor_row < len(services):
|
||||
new_row = self._last_cursor_row
|
||||
if new_row >= 0:
|
||||
self.table.move_cursor(row=new_row)
|
||||
self._update_action_status()
|
||||
|
||||
def focus_table(self) -> None:
|
||||
self.table.focus()
|
||||
|
||||
def action_start_service(self) -> None:
|
||||
self._run_action("start")
|
||||
|
||||
def action_stop_service(self) -> None:
|
||||
self._run_action("stop")
|
||||
|
||||
def action_restart_service(self) -> None:
|
||||
self._run_action("restart")
|
||||
|
||||
def _run_action(self, action: str) -> None:
|
||||
info = self._selected_service()
|
||||
if info is None:
|
||||
self.app.notify("no service selected", severity="warning", timeout=3)
|
||||
return
|
||||
|
||||
def on_confirm(confirmed: bool) -> None:
|
||||
if not confirmed:
|
||||
return
|
||||
self._execute_action(action, info)
|
||||
|
||||
self.app.push_screen(ConfirmActionScreen(action, info.name), on_confirm)
|
||||
|
||||
def _selected_service(self) -> ServiceInfo | None:
|
||||
coord = self.table.cursor_coordinate
|
||||
if coord.row < 0 or coord.row >= len(self._services):
|
||||
return None
|
||||
return self._services[coord.row]
|
||||
|
||||
def _execute_action(self, action: str, info: ServiceInfo) -> None:
|
||||
self.app.notify(
|
||||
f"{action} {info.name}...",
|
||||
severity="information",
|
||||
timeout=3,
|
||||
)
|
||||
|
||||
async def run() -> None:
|
||||
unit = info.unit if info.unit else f"{info.name}.service"
|
||||
cmd = f"systemctl {action} {unit} 2>&1"
|
||||
result = await self.executor.run(cmd, timeout=30.0)
|
||||
if result.ok:
|
||||
self.app.notify(
|
||||
f"{action} {info.name} OK",
|
||||
severity="information",
|
||||
timeout=3,
|
||||
)
|
||||
else:
|
||||
self.app.notify(
|
||||
f"{action} {info.name} failed: {result.stderr or result.stdout}",
|
||||
severity="error",
|
||||
timeout=6,
|
||||
)
|
||||
|
||||
self.app.run_worker(run(), group="service-action", exclusive=False)
|
||||
|
||||
def _update_action_status(self) -> None:
|
||||
info = self._selected_service()
|
||||
name = info.name if info else "—"
|
||||
running = sum(1 for s in self._services if s.running)
|
||||
self.status_bar.update(
|
||||
f"[dim]{running}/{len(self._services)} running[/] │ "
|
||||
f"selected: [bold]{name}[/] │ Start Stop Restart"
|
||||
)
|
||||
|
||||
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
||||
if event.data_table.id == "services-table":
|
||||
self._update_action_status()
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
mapping = {
|
||||
"btn-start": "start",
|
||||
"btn-stop": "stop",
|
||||
"btn-restart": "restart",
|
||||
}
|
||||
action = mapping.get(event.button.id or "")
|
||||
if action:
|
||||
self._run_action(action)
|
||||
|
|
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||
|
||||
[project]
|
||||
name = "dsmview"
|
||||
version = "0.3.1"
|
||||
version = "0.2.1"
|
||||
description = "TUI SSH monitor for Synology DSM 7.x"
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue