Compare commits
8 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f63599af65 | |||
|
|
a67c5c8214 | ||
| 57d3c97ed4 | |||
|
|
093960ffe4 | ||
| a219c333ff | |||
|
|
7b1bf9ae71 | ||
| ba3c11dc95 | |||
|
|
cc2f8c6dda |
10 changed files with 430 additions and 47 deletions
|
|
@ -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.1.0.tar.gz"
|
url "https://github.com/janvete/dsmview/archive/refs/tags/v0.3.1.tar.gz"
|
||||||
sha256 "ea00a4f40a90d38ffea4f6d27b4be6b9ee71a5f4339f6562713aa42a766a9e77"
|
sha256 "6f0fcda846ee254604b87e06f3eaf20f58cdb148573cc30cb0fc7f31e922d665"
|
||||||
license "MIT"
|
license "MIT"
|
||||||
head "https://github.com/janvete/dsmview.git", branch: "main"
|
head "https://github.com/janvete/dsmview.git", branch: "main"
|
||||||
|
|
||||||
|
|
|
||||||
48
README.md
48
README.md
|
|
@ -2,11 +2,31 @@
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
@ -29,20 +49,28 @@ 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
|
||||||
|
(or use the Start / Stop / Restart buttons)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
__version__ = "0.2.1"
|
__version__ = "0.3.1"
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
@ -89,11 +98,35 @@ 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()
|
||||||
|
|
@ -133,6 +166,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"]
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,10 @@ from __future__ import annotations
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from typing import Deque
|
from typing import Deque
|
||||||
|
|
||||||
|
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
|
||||||
|
|
@ -15,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()
|
||||||
|
|
@ -53,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 = {
|
||||||
|
|
@ -66,8 +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]
|
||||||
src = f"[dim]{line.source:<8}[/]"
|
ts = escape(line.timestamp) if line.timestamp else ""
|
||||||
return f"{src} [{color}]{line.raw}[/]"
|
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:
|
def _rerender(self) -> None:
|
||||||
self.log_view.clear()
|
self.log_view.clear()
|
||||||
|
|
|
||||||
219
dsmview/ui/tabs/services.py
Normal file
219
dsmview/ui/tabs/services.py
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
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]
|
[project]
|
||||||
name = "dsmview"
|
name = "dsmview"
|
||||||
version = "0.2.1"
|
version = "0.3.1"
|
||||||
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