diff --git a/README.md b/README.md index 2450a02..912baef 100644 --- a/README.md +++ b/README.md @@ -29,20 +29,27 @@ service — and that requires explicit confirmation in the UI. ## Tabs - **F1 Dashboard** — CPU history graph, memory, storage pools, network - throughput, load average, temperatures. -- **F2 Logs** — live tail of `/var/log/messages` and `/var/log/auth.log` - with severity filters (ALL / ERROR / WARN / SECURITY / INFO). -- **F3 Services** — `synoservicectl --list` table with up/down status and - restart/stop/start actions. -- **F4 Disks** — per-disk model/size/temp/health from `smartctl` plus RAID - status from `/proc/mdstat`. + throughput, load average, temperatures, services summary and disk health. +- **F2 Logs** — live tail of system, auth and package logs (including + Active Backup) with severity filters and text search. +- **F3 Services** — `systemctl` service list with up/down status and + start/stop/restart actions. ## Keys ``` -F1..F4 switch tab -r refresh now -q quit +F1..F3 switch tab +r refresh now +q quit +/ focus log search +ESC clear log search +Logs tab: + a / 1-4 severity filter (All / Error / Warn / Security / Info) +Services tab: + ↑/↓ select service + s start + S stop + R restart ``` ## Requirements diff --git a/dsmview/__init__.py b/dsmview/__init__.py index b5fdc75..493f741 100644 --- a/dsmview/__init__.py +++ b/dsmview/__init__.py @@ -1 +1 @@ -__version__ = "0.2.2" +__version__ = "0.3.0" diff --git a/dsmview/collectors/logs.py b/dsmview/collectors/logs.py index c9cf61d..92ec4ee 100644 --- a/dsmview/collectors/logs.py +++ b/dsmview/collectors/logs.py @@ -16,10 +16,36 @@ class Severity(str, Enum): OTHER = "OTHER" -_ERROR_RE = re.compile(r"\b(error|fail(?:ed|ure)?|critical|EXT4-fs error|I/O error|RAID degraded|panic|oom)\b", re.IGNORECASE) -_WARN_RE = re.compile(r"\b(warn(?:ing)?|temperature|fan speed|link down|throttle)\b", re.IGNORECASE) -_SECURITY_RE = re.compile(r"\b(Failed password|Invalid user|authentication failure|sudo|session opened|session closed)\b") -_INFO_RE = re.compile(r"\b(completed|started|stopped|success|updated|finished)\b", re.IGNORECASE) +_ERROR_RE = re.compile( + r"\b(error|fail(?:ed|ure)?|critical|EXT4-fs error|I/O error|RAID degraded|" + r"panic|oom|crashed|corrupt|cannot|unable)\b", + re.IGNORECASE, +) +_WARN_RE = re.compile( + r"\b(warn(?:ing)?|temperature|fan speed|link down|throttle|offline)\b", + re.IGNORECASE, +) +_SECURITY_RE = re.compile( + r"\b(Failed password|Invalid user|authentication failure|sudo|" + r"session opened|session closed)\b" +) +_INFO_RE = re.compile( + r"\b(completed|started|stopped|success|updated|finished|backup task)\b", + re.IGNORECASE, +) + +# Try to strip leading syslog timestamp so we can show a short date prefix. +_SYSLOG_TS_RE = re.compile( + r"^([A-Z][a-z]{2}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}|" + r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[\+\-]\d{2}:\d{2}|" + r"\d{4}/\d{2}/\d{2}\s+\d{2}:\d{2}:\d{2})" +) + + +@dataclass(slots=True) +class LogSource: + path: str + label: str @dataclass(slots=True) @@ -27,6 +53,8 @@ class LogLine: raw: str source: str severity: Severity = Severity.OTHER + timestamp: str = "" + message: str = "" @classmethod def classify(cls, raw: str, source: str) -> "LogLine": @@ -41,29 +69,47 @@ class LogLine: sev = Severity.WARN elif _INFO_RE.search(raw): sev = Severity.INFO - return cls(raw=raw, source=source, severity=sev) + + # Extract a leading timestamp, if present. + ts_match = _SYSLOG_TS_RE.match(raw) + timestamp = ts_match.group(1) if ts_match else "" + message = raw[ts_match.end() :].lstrip() if ts_match else raw + return cls( + raw=raw, + source=source, + severity=sev, + timestamp=timestamp, + message=message, + ) class LogCollector(Collector[List[LogLine]]): name = "logs" - SOURCES = ( - ("/var/log/messages", "messages"), - ("/var/log/auth.log", "auth"), + # (path, short_label). Paths are checked at runtime so missing files + # simply yield no lines. + SOURCES: tuple[LogSource, ...] = ( + LogSource("/var/log/messages", "messages"), + LogSource("/var/log/auth.log", "auth"), + LogSource("/var/log/packages/ActiveBackup.log", "abb-pkg"), + # The ABB activity log is usually under /volumeX/@ActiveBackup/log. + # The package exposes a symlink at /var/packages/ActiveBackup/target/log. + LogSource("/var/packages/ActiveBackup/target/log/activity.log", "abb-activity"), ) async def collect(self) -> List[LogLine]: - cmds = [f"tail -n 200 {path} 2>/dev/null || true" for path, _ in self.SOURCES] + cmds = [f"tail -n 200 {src.path} 2>/dev/null || true" for src in self.SOURCES] results = await self.executor.gather(cmds, timeout=10.0) lines: List[LogLine] = [] - for (_, source), res in zip(self.SOURCES, results): + for src, res in zip(self.SOURCES, results): for raw in res.stdout.splitlines(): if raw.strip(): - lines.append(LogLine.classify(raw, source)) + lines.append(LogLine.classify(raw, src.label)) + # Newest last so the RichLog scrolls to the bottom naturally. return lines async def tail(self, source_index: int = 0) -> AsyncIterator[LogLine]: - path, source = self.SOURCES[source_index] - async for raw in self.executor.tail(path): + src = self.SOURCES[source_index] + async for raw in self.executor.tail(src.path): if raw.strip(): - yield LogLine.classify(raw, source) + yield LogLine.classify(raw, src.label) diff --git a/dsmview/collectors/services.py b/dsmview/collectors/services.py index ea2930e..8b0b545 100644 --- a/dsmview/collectors/services.py +++ b/dsmview/collectors/services.py @@ -8,7 +8,10 @@ from dsmview.collectors.base import Collector @dataclass(slots=True) class ServiceInfo: + # Human-readable name shown in the UI. name: str + # Full systemd unit name, e.g. "pkg-ActiveBackup-logd.service". + unit: str = "" running: bool = False description: str = "" @@ -65,7 +68,14 @@ class ServicesCollector(Collector[ServicesSnapshot]): # smbd shows as smbd, not pkg-smbd. display = name[4:] if name.startswith("pkg-") else name running = sub_state == "running" - out.append(ServiceInfo(name=display, running=running, description=desc.strip().strip('"'))) + out.append( + ServiceInfo( + name=display, + unit=unit, + running=running, + description=desc.strip().strip('"'), + ) + ) # Sort: interesting services first, then running ones, then the rest. def sort_key(s: ServiceInfo) -> tuple: interesting = 0 if s.name in INTERESTING else 1 diff --git a/dsmview/ui/app.py b/dsmview/ui/app.py index e602983..f83cabf 100644 --- a/dsmview/ui/app.py +++ b/dsmview/ui/app.py @@ -18,7 +18,7 @@ from dsmview.collectors import ( from dsmview.ssh.connection import NasConnection from dsmview.ssh.executor import Executor from dsmview.ui import theme -from dsmview.ui.tabs import DashboardTab, LogsTab +from dsmview.ui.tabs import DashboardTab, LogsTab, ServicesTab # Three refresh tiers — fast for live metrics, medium for storage/services, @@ -37,11 +37,17 @@ class DsmviewApp(App): Binding("r", "refresh_now", "Refresh"), Binding("f1", "switch_tab('dashboard')", "Dash"), Binding("f2", "switch_tab('logs')", "Logs"), + Binding("f3", "switch_tab('services')", "Services"), Binding("a", "filter('ALL')", "All", show=False), Binding("1", "filter('ERROR')", "Err", show=False), Binding("2", "filter('WARN')", "Warn", show=False), Binding("3", "filter('SECURITY')", "Sec", show=False), Binding("4", "filter('INFO')", "Info", show=False), + Binding("/", "focus_search", "Search", show=False), + Binding("escape", "clear_search", "ClearSearch", show=False), + Binding("s", "start_service", "Start", show=False), + Binding("S", "stop_service", "Stop", show=False), + Binding("R", "restart_service", "Restart", show=False), ] def __init__(self, connection: NasConnection) -> None: @@ -67,6 +73,9 @@ class DsmviewApp(App): with TabPane("F2 Logs", id="logs"): self.logs_tab = LogsTab() yield self.logs_tab + with TabPane("F3 Services", id="services"): + self.services_tab = ServicesTab(self.executor) + yield self.services_tab yield Footer() def on_mount(self) -> None: @@ -94,6 +103,28 @@ class DsmviewApp(App): self.query_one(TabbedContent).active = "logs" self.logs_tab.set_filter(name) + def action_focus_search(self) -> None: + self.query_one(TabbedContent).active = "logs" + self.logs_tab.show_search() + + def action_clear_search(self) -> None: + self.logs_tab.hide_search() + + def _on_services_tab(self, action_name: str) -> None: + active = self.query_one(TabbedContent).active + if active != "services": + return + getattr(self.services_tab, action_name)() + + def action_start_service(self) -> None: + self._on_services_tab("action_start_service") + + def action_stop_service(self) -> None: + self._on_services_tab("action_stop_service") + + def action_restart_service(self) -> None: + self._on_services_tab("action_restart_service") + def action_refresh_now(self) -> None: self._tick_fast() self._tick_medium() @@ -133,6 +164,7 @@ class DsmviewApp(App): return self.dashboard.update_storage(storage_snap) self.dashboard.update_services(services_snap.services) + self.services_tab.update(services_snap.services) async def _refresh_slow(self) -> None: try: diff --git a/dsmview/ui/tabs/__init__.py b/dsmview/ui/tabs/__init__.py index 42e0223..43d6c6a 100644 --- a/dsmview/ui/tabs/__init__.py +++ b/dsmview/ui/tabs/__init__.py @@ -1,4 +1,5 @@ from dsmview.ui.tabs.dashboard import DashboardTab from dsmview.ui.tabs.logs import LogsTab +from dsmview.ui.tabs.services import ServicesTab -__all__ = ["DashboardTab", "LogsTab"] +__all__ = ["DashboardTab", "LogsTab", "ServicesTab"] diff --git a/dsmview/ui/tabs/logs.py b/dsmview/ui/tabs/logs.py index c189a1b..a3c8b01 100644 --- a/dsmview/ui/tabs/logs.py +++ b/dsmview/ui/tabs/logs.py @@ -6,7 +6,7 @@ from typing import Deque from rich.markup import escape from textual.app import ComposeResult from textual.containers import Container -from textual.widgets import RichLog, Static +from textual.widgets import Input, RichLog, Static from dsmview.collectors.logs import LogLine, Severity from dsmview.ui import theme @@ -16,34 +16,66 @@ _MAX_LINES = 2000 class LogsTab(Container): - """Live log tail with severity filter. The filter keys (a / 1-4) are - bound at the App level — pressing them anywhere switches to this tab - and applies the filter.""" + """Live log tail with severity and text filters. + + Severity filters (a / 1-4) are bound at the App level. Press ``/`` to + start a text search and ``escape`` to clear it. + """ def __init__(self) -> None: super().__init__() self._buffer: Deque[LogLine] = deque(maxlen=_MAX_LINES) - self._filter: str = "ALL" + self._severity: str = "ALL" + self._search: str = "" def compose(self) -> ComposeResult: with Container(classes="panel"): yield Static( - "LOGS — [A]LL [1]ERROR [2]WARN [3]SECURITY [4]INFO", + "LOGS — [A]LL [1]ERROR [2]WARN [3]SECURITY [4]INFO [/]/ search ESC clear", classes="panel-title", id="logs-header", ) self.status_bar = Static("filter: ALL", id="logs-status") yield self.status_bar + self.search_input = Input( + placeholder="search logs...", + id="logs-search", + ) + self.search_input.display = False + yield self.search_input self.log_view = RichLog( - highlight=False, markup=True, wrap=False, max_lines=_MAX_LINES, id="logs-view" + highlight=False, + markup=True, + wrap=False, + max_lines=_MAX_LINES, + id="logs-view", ) yield self.log_view def set_filter(self, name: str) -> None: - self._filter = name - self.status_bar.update(f"filter: {name}") + self._severity = name + self._update_status() self._rerender() + def set_search(self, text: str) -> None: + self._search = text.strip().lower() + self._update_status() + self._rerender() + + def show_search(self) -> None: + self.search_input.display = True + self.search_input.focus() + + def hide_search(self) -> None: + self.search_input.display = False + self.search_input.value = "" + self.set_search("") + self.log_view.focus() + + def on_input_changed(self, event: Input.Changed) -> None: + if event.input.id == "logs-search": + self.set_search(event.value) + def push_initial(self, lines: list[LogLine]) -> None: self._buffer.extend(lines) self._rerender() @@ -54,9 +86,12 @@ class LogsTab(Container): self.log_view.write(self._format(line)) def _matches(self, line: LogLine) -> bool: - if self._filter == "ALL": - return True - return line.severity.value == self._filter + if self._severity != "ALL" and line.severity.value != self._severity: + return False + if self._search: + haystack = (line.raw + " " + line.source).lower() + return self._search in haystack + return True def _format(self, line: LogLine) -> str: color_for = { @@ -67,11 +102,17 @@ class LogsTab(Container): Severity.OTHER: theme.BORDER_TITLE, } color = color_for[line.severity] - # Raw log lines often contain literal brackets like - # `[/path/to/file]` which Rich's markup parser tries to interpret - # as closing tags. Escape so they render as text. - src = f"[dim]{line.source:<8}[/]" - return f"{src} [{color}]{escape(line.raw)}[/]" + ts = escape(line.timestamp) if line.timestamp else "" + ts_part = f"[dim]{ts:<28}[/] " if ts else "" + src = f"[dim]{line.source:<12}[/]" + body = escape(line.message or line.raw) + return f"{ts_part}{src} [{color}]{body}[/]" + + def _update_status(self) -> None: + parts = [f"severity: {self._severity}"] + if self._search: + parts.append(f"search: \"{self._search}\"") + self.status_bar.update(" ".join(parts)) def _rerender(self) -> None: self.log_view.clear() diff --git a/dsmview/ui/tabs/services.py b/dsmview/ui/tabs/services.py new file mode 100644 index 0000000..ddfd934 --- /dev/null +++ b/dsmview/ui/tabs/services.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml index 26591ce..dc89365 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "dsmview" -version = "0.2.2" +version = "0.3.0" description = "TUI SSH monitor for Synology DSM 7.x" readme = "README.md" license = { text = "MIT" }