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.
This commit is contained in:
parent
7b1bf9ae71
commit
a219c333ff
9 changed files with 327 additions and 46 deletions
23
README.md
23
README.md
|
|
@ -29,20 +29,27 @@ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
__version__ = "0.2.2"
|
__version__ = "0.3.0"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
@ -94,6 +103,28 @@ class DsmviewApp(App):
|
||||||
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 +164,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:
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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 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
|
||||||
|
|
@ -16,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()
|
||||||
|
|
@ -54,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 False
|
||||||
|
if self._search:
|
||||||
|
haystack = (line.raw + " " + line.source).lower()
|
||||||
|
return self._search in haystack
|
||||||
return True
|
return True
|
||||||
return line.severity.value == self._filter
|
|
||||||
|
|
||||||
def _format(self, line: LogLine) -> str:
|
def _format(self, line: LogLine) -> str:
|
||||||
color_for = {
|
color_for = {
|
||||||
|
|
@ -67,11 +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]
|
||||||
# Raw log lines often contain literal brackets like
|
ts = escape(line.timestamp) if line.timestamp else ""
|
||||||
# `[/path/to/file]` which Rich's markup parser tries to interpret
|
ts_part = f"[dim]{ts:<28}[/] " if ts else ""
|
||||||
# as closing tags. Escape so they render as text.
|
src = f"[dim]{line.source:<12}[/]"
|
||||||
src = f"[dim]{line.source:<8}[/]"
|
body = escape(line.message or line.raw)
|
||||||
return f"{src} [{color}]{escape(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()
|
||||||
|
|
|
||||||
144
dsmview/ui/tabs/services.py
Normal file
144
dsmview/ui/tabs/services.py
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.containers import Container, Horizontal
|
||||||
|
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(Container):
|
||||||
|
"""Interactive service list with start/stop/restart actions."""
|
||||||
|
|
||||||
|
def __init__(self, executor: Executor) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.executor = executor
|
||||||
|
self._services: list[ServiceInfo] = []
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Container(classes="panel"):
|
||||||
|
yield Static(
|
||||||
|
"SERVICES — ↑/↓ select [s]tart [S]top [R]estart",
|
||||||
|
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")
|
||||||
|
self.table.add_columns("SERVICE", "STATUS", "DESCRIPTION")
|
||||||
|
yield self.table
|
||||||
|
|
||||||
|
def update(self, services: list[ServiceInfo]) -> None:
|
||||||
|
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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "dsmview"
|
name = "dsmview"
|
||||||
version = "0.2.2"
|
version = "0.3.0"
|
||||||
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" }
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue