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:
SW 2026-06-17 20:32:04 +02:00
parent 7b1bf9ae71
commit a219c333ff
9 changed files with 327 additions and 46 deletions

View file

@ -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

View file

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

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:
@ -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:

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

@ -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 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 = {
@ -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
View 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)

View file

@ -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" }