diff --git a/backend/src/media_library_viewer_api/clients/local.py b/backend/src/media_library_viewer_api/clients/local.py new file mode 100644 index 0000000..e07ec17 --- /dev/null +++ b/backend/src/media_library_viewer_api/clients/local.py @@ -0,0 +1,60 @@ +"""Local command execution helpers. + +These mirror the remote SSH helpers but execute commands on the API host +itself. They are used for the built-in local monitoring machine. +""" + +from __future__ import annotations + +import logging +import posixpath +import subprocess +from dataclasses import dataclass + +logger = logging.getLogger(__name__) + + +@dataclass +class CommandResult: + """Plain result object returned by local command execution.""" + + command: str + exit_status: int + stdout: str + stderr: str + + +class LocalCommandClient: + """Execute the same POSIX shell snippets used by the SSH client locally.""" + + def __init__(self, timeout: int = 20): + self.timeout = timeout + + def run(self, command: str, timeout: int | None = None) -> CommandResult: + shell_command = ["/bin/sh", "-c", command] + logger.debug("Local run timeout=%s command=%s", timeout or self.timeout, command) + proc = subprocess.run( + shell_command, + capture_output=True, + text=True, + timeout=timeout or self.timeout, + ) + result = CommandResult( + command=command, + exit_status=proc.returncode, + stdout=proc.stdout, + stderr=proc.stderr, + ) + if result.exit_status == 0: + logger.debug("Local command ok exit_status=%s", result.exit_status) + else: + logger.warning( + "Local command failed exit_status=%s stderr=%s", + result.exit_status, + result.stderr.strip() or result.stdout.strip(), + ) + return result + + @staticmethod + def join(parent: str, child: str) -> str: + return posixpath.normpath(posixpath.join(parent, child)) diff --git a/backend/src/media_library_viewer_api/routers/settings.py b/backend/src/media_library_viewer_api/routers/settings.py new file mode 100644 index 0000000..6e768e5 --- /dev/null +++ b/backend/src/media_library_viewer_api/routers/settings.py @@ -0,0 +1,175 @@ +"""Settings router for persistent machine definitions, app credentials, and data reset.""" + +from __future__ import annotations + +from io import StringIO +from typing import Any + +import paramiko +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field + +from media_library_viewer_api.dependencies import get_settings_store +from media_library_viewer_api.services.db_maintenance import remove_sqlite_database +from media_library_viewer_api.services.media_index import MediaIndex +from media_library_viewer_api.services.settings_store import SettingsStore + +router = APIRouter(prefix="/api/settings", tags=["settings"]) + + +class MonitoringMachineInput(BaseModel): + """Payload for creating or updating a machine.""" + + id: str | None = None + name: str = Field(default="") + mode: str = Field(default="local", description="local or ssh") + enabled: bool = True + services: list[str] = Field(default_factory=list) + host: str = "" + port: int = 22 + username: str = "" + key_directory: str = "" + key_name: str = "" + ssh_key_id: str = "" + ssh_private_key: str = "" + ssh_private_key_passphrase: str = "" + password: str = "" + media_root: str = "" + path_prefix: str = "" + jellyfin_url: str = "" + jellyfin_user_id: str = "" + jellyfin_api_key: str = "" + jellyseerr_url: str = "" + jellyseerr_api_key: str = "" + notes: str = "" + + +@router.get("/machines") +def get_machines(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]: + return store.list_machines() + + +@router.post("/machines", status_code=status.HTTP_201_CREATED) +def post_machine( + machine: MonitoringMachineInput, + store: SettingsStore = Depends(get_settings_store), +) -> dict[str, Any]: + return store.upsert_machine(machine.model_dump(exclude_none=True), machine.id) + + +@router.put("/machines/{machine_id}") +def put_machine( + machine_id: str, + machine: MonitoringMachineInput, + store: SettingsStore = Depends(get_settings_store), +) -> dict[str, Any]: + if not store.get_machine(machine_id): + raise HTTPException(status_code=404, detail="Machine not found") + return store.upsert_machine(machine.model_dump(exclude_none=True), machine_id) + + +@router.delete("/machines/{machine_id}") +def delete_machine(machine_id: str, store: SettingsStore = Depends(get_settings_store)) -> dict[str, str]: + if not store.get_machine(machine_id): + raise HTTPException(status_code=404, detail="Machine not found") + store.delete_machine(machine_id) + return {"status": "deleted"} + + +class SSHKeyInput(BaseModel): + id: str | None = None + name: str = Field(default="") + private_key: str = Field(default="") + passphrase: str = Field(default="") + notes: str = Field(default="") + + +class SSHKeyGenerateInput(BaseModel): + name: str = Field(default="") + passphrase: str = Field(default="") + notes: str = Field(default="") + bits: int = Field(default=4096, ge=2048, le=8192) + + +@router.post("/ssh-keys/generate") +def generate_ssh_key( + payload: SSHKeyGenerateInput, +) -> dict[str, Any]: + key = paramiko.RSAKey.generate(bits=payload.bits) + private_buffer = StringIO() + key.write_private_key(private_buffer, password=payload.passphrase or None) + private_key = private_buffer.getvalue() + public_key = f"{key.get_name()} {key.get_base64()}" + return { + "name": payload.name, + "private_key": private_key, + "passphrase": payload.passphrase, + "notes": payload.notes, + "public_key": public_key, + } + + +@router.get("/ssh-keys") +def get_ssh_keys(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]: + return store.list_ssh_keys() + + +@router.post("/ssh-keys", status_code=status.HTTP_201_CREATED) +def post_ssh_key( + key: SSHKeyInput, + store: SettingsStore = Depends(get_settings_store), +) -> dict[str, Any]: + return store.upsert_ssh_key(key.model_dump(exclude_none=True), key.id) + + +@router.put("/ssh-keys/{key_id}") +def put_ssh_key( + key_id: str, + key: SSHKeyInput, + store: SettingsStore = Depends(get_settings_store), +) -> dict[str, Any]: + if not store.get_ssh_key(key_id): + raise HTTPException(status_code=404, detail="SSH key not found") + return store.upsert_ssh_key(key.model_dump(exclude_none=True), key_id) + + +@router.delete("/ssh-keys/{key_id}") +def delete_ssh_key(key_id: str, store: SettingsStore = Depends(get_settings_store)) -> dict[str, str]: + if not store.get_ssh_key(key_id): + raise HTTPException(status_code=404, detail="SSH key not found") + store.delete_ssh_key(key_id) + return {"status": "deleted"} + + +class ResetLocalDatabaseInput(BaseModel): + confirm_phrase: str = Field(..., description="Must be RESET LOCAL DATABASE") + acknowledge_settings_loss: bool = False + acknowledge_media_index_loss: bool = False + acknowledge_irreversible: bool = False + + +@router.post("/reset-local-database") +def reset_local_database( + payload: ResetLocalDatabaseInput, + store: SettingsStore = Depends(get_settings_store), +) -> dict[str, Any]: + expected = "RESET LOCAL DATABASE" + if payload.confirm_phrase.strip().upper() != expected: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Confirmation phrase does not match") + if not (payload.acknowledge_settings_loss and payload.acknowledge_media_index_loss and payload.acknowledge_irreversible): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="All confirmation checkboxes must be selected") + + settings_removed = remove_sqlite_database(store.db_path) + media_index = MediaIndex() + media_removed = remove_sqlite_database(media_index.db_path) + + # Recreate the default local machine immediately so the UI remains usable. + store.ensure_defaults() + + return { + "status": "reset", + "settings_db_removed": bool(settings_removed), + "media_index_removed": bool(media_removed), + "settings_files": settings_removed, + "media_index_files": media_removed, + } diff --git a/backend/src/media_library_viewer_api/routers/tasks.py b/backend/src/media_library_viewer_api/routers/tasks.py new file mode 100644 index 0000000..6c66689 --- /dev/null +++ b/backend/src/media_library_viewer_api/routers/tasks.py @@ -0,0 +1,197 @@ +"""Saved server actions/tasks router.""" + +from __future__ import annotations + +import logging +import shlex +import time +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from pydantic import BaseModel, Field + +from media_library_viewer_api.clients.local import LocalCommandClient +from media_library_viewer_api.clients.ssh import RemoteSSHClient +from media_library_viewer_api.config import get_settings +from media_library_viewer_api.dependencies import get_settings_store +from media_library_viewer_api.services.known_hosts import ensure_known_host +from media_library_viewer_api.services.settings_store import SettingsStore + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/tasks", tags=["tasks"]) + + +class TaskInput(BaseModel): + id: str | None = None + name: str = Field(default="") + task_type: str = Field(default="shell", description="shell or python") + content: str = Field(default="") + enabled: bool = True + default_machine_id: str = "" + notes: str = "" + + +class RunTaskRequest(BaseModel): + task_id: str + + +def _machine_label(machine: dict[str, Any] | None) -> str: + if not machine: + return "" + return str(machine.get("name") or machine.get("host") or machine.get("id") or "") + + +def _resolve_machine_for_task( + store: SettingsStore, + task: dict[str, Any], + machine_id: str | None, +) -> dict[str, Any] | None: + if machine_id: + return store.get_machine_config(machine_id) or store.get_machine(machine_id) + default_machine_id = str(task.get("default_machine_id") or "").strip() + if default_machine_id: + return store.get_machine_config(default_machine_id) or store.get_machine(default_machine_id) + machines = [machine for machine in store.list_machines() if machine.get("enabled")] + return machines[0] if machines else None + + +def _client_for_machine(store: SettingsStore, machine: dict[str, Any]): + mode = str(machine.get("mode") or "local").lower() + if mode == "local": + return LocalCommandClient() + + host = str(machine.get("host") or "").strip() + username = str(machine.get("username") or "").strip() + if not host or not username: + raise HTTPException(status_code=400, detail="SSH machine is missing host or username") + + settings = get_settings() + ensure_known_host(host, int(machine.get("port") or 22), settings.ssh_known_hosts_file) + + private_key = str(machine.get("ssh_private_key") or "") + passphrase = str(machine.get("ssh_private_key_passphrase") or "") + ssh_key_id = str(machine.get("ssh_key_id") or "").strip() + if ssh_key_id: + ssh_key = store.get_ssh_key(ssh_key_id) + if ssh_key: + private_key = str(ssh_key.get("private_key") or private_key) + passphrase = str(ssh_key.get("passphrase") or passphrase) + + key_filename = "" + if machine.get("key_directory") and machine.get("key_name"): + key_filename = f"{machine.get('key_directory')}/{machine.get('key_name')}" + + return RemoteSSHClient( + host=host, + username=username, + port=int(machine.get("port") or 22), + key_filename=key_filename or None, + private_key=private_key or None, + private_key_passphrase=passphrase or None, + password=str(machine.get("password") or "") or None, + known_hosts_path=str(settings.ssh_known_hosts_file), + ) + + +@router.get("") +def list_tasks(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]: + return store.list_tasks() + + +@router.post("", status_code=status.HTTP_201_CREATED) +def create_task(task: TaskInput, store: SettingsStore = Depends(get_settings_store)) -> dict[str, Any]: + return store.upsert_task(task.model_dump(exclude_none=True), task.id) + + +@router.put("/{task_id}") +def update_task(task_id: str, task: TaskInput, store: SettingsStore = Depends(get_settings_store)) -> dict[str, Any]: + if not store.get_task(task_id): + raise HTTPException(status_code=404, detail="Task not found") + return store.upsert_task(task.model_dump(exclude_none=True), task_id) + + +@router.delete("/{task_id}") +def delete_task(task_id: str, store: SettingsStore = Depends(get_settings_store)) -> dict[str, str]: + if not store.get_task(task_id): + raise HTTPException(status_code=404, detail="Task not found") + store.delete_task(task_id) + return {"status": "deleted"} + + +@router.get("/{task_id}/runs") +def list_task_runs( + task_id: str, + limit: int = Query(default=10, ge=1, le=50), + store: SettingsStore = Depends(get_settings_store), +) -> dict[str, Any]: + if not store.get_task(task_id): + raise HTTPException(status_code=404, detail="Task not found") + runs = store.list_task_runs(task_id, limit=limit) + return {"items": runs, "total": len(runs)} + + +@router.post("/run") +def run_task( + request: RunTaskRequest, + machine_id: str | None = Query(default=None), + store: SettingsStore = Depends(get_settings_store), +) -> dict[str, Any]: + task = store.get_task(request.task_id) + if not task: + raise HTTPException(status_code=404, detail="Task not found") + if not task.get("enabled", True): + raise HTTPException(status_code=400, detail="Task is disabled") + + machine = _resolve_machine_for_task(store, task, machine_id) + if not machine: + raise HTTPException(status_code=400, detail="No machine is available for this action") + + task_type = str(task.get("task_type") or "shell").lower() + command = str(task.get("content") or "") + if task_type == "python": + command = f"python3 -c {shlex.quote(command)}" + elif task_type != "shell": + raise HTTPException(status_code=400, detail=f"Unknown task type: {task_type}") + + client = _client_for_machine(store, machine) + start = time.perf_counter() + machine_name = _machine_label(machine) + try: + result = client.run(command, timeout=1200) + status_text = "success" if result.exit_status == 0 else "error" + store.record_task_run( + task, + status_text, + machine_id=str(machine.get("id") or ""), + machine_name=machine_name, + task_type=task_type, + duration_ms=int((time.perf_counter() - start) * 1000), + stdout_tail=result.stdout[-4000:], + stderr_tail=result.stderr[-4000:], + error="" if result.exit_status == 0 else (result.stderr or result.stdout or "Task failed"), + ) + return { + "task_id": task["id"], + "task_name": task["name"], + "machine_id": str(machine.get("id") or ""), + "machine_name": machine_name, + "task_type": task_type, + "exit_status": result.exit_status, + "stdout": result.stdout, + "stderr": result.stderr, + } + except Exception as exc: + store.record_task_run( + task, + "error", + machine_id=str(machine.get("id") or ""), + machine_name=machine_name, + task_type=task_type, + duration_ms=int((time.perf_counter() - start) * 1000), + stdout_tail="", + stderr_tail="", + error=str(exc), + ) + logger.exception("Task execution failed task_id=%s", task["id"]) + raise diff --git a/backend/src/media_library_viewer_api/services/db_maintenance.py b/backend/src/media_library_viewer_api/services/db_maintenance.py new file mode 100644 index 0000000..f9caac3 --- /dev/null +++ b/backend/src/media_library_viewer_api/services/db_maintenance.py @@ -0,0 +1,27 @@ +"""Helpers for removing local SQLite databases safely.""" + +from __future__ import annotations + +from pathlib import Path + + +def sqlite_sidecar_paths(db_path: Path) -> list[Path]: + """Return the SQLite database file and its WAL/SHM sidecars.""" + db_path = Path(db_path) + return [ + db_path, + db_path.with_name(db_path.name + "-wal"), + db_path.with_name(db_path.name + "-shm"), + ] + + +def remove_sqlite_database(db_path: Path) -> list[str]: + """Delete the SQLite database file plus WAL/SHM sidecars if present.""" + removed: list[str] = [] + for path in sqlite_sidecar_paths(db_path): + try: + path.unlink() + removed.append(str(path)) + except FileNotFoundError: + continue + return removed diff --git a/backend/src/media_library_viewer_api/services/known_hosts.py b/backend/src/media_library_viewer_api/services/known_hosts.py new file mode 100644 index 0000000..dcc6696 --- /dev/null +++ b/backend/src/media_library_viewer_api/services/known_hosts.py @@ -0,0 +1,85 @@ +"""Managed known_hosts synthesis for SSH clients. + +Instead of mounting a host-side ``known_hosts`` file, the backend can discover +and persist host keys for configured SSH machines inside its own cache volume. +This keeps strict host-key checking enabled without exposing a whole SSH +configuration directory into the container. +""" + +from __future__ import annotations + +import logging +import socket +from pathlib import Path +from typing import Any + +import paramiko + +logger = logging.getLogger(__name__) + + +def _host_alias(host: str, port: int) -> str: + return host if int(port or 22) == 22 else f"[{host}]:{int(port or 22)}" + + +def _fetch_server_key(host: str, port: int, timeout: int = 10) -> paramiko.PKey: + sock = socket.create_connection((host, int(port or 22)), timeout=timeout) + transport = paramiko.Transport(sock) + try: + transport.start_client(timeout=timeout) + key = transport.get_remote_server_key() + if key is None: + raise RuntimeError(f"Unable to read SSH host key for {host}:{port}") + return key + finally: + transport.close() + sock.close() + + +def ensure_known_host(host: str, port: int, known_hosts_path: Path, *, strict: bool = True) -> bool: + """Ensure a host key entry exists for the given host/port. + + Returns ``True`` when the file was changed. If ``strict`` is enabled and the + existing key differs, a ``RuntimeError`` is raised instead of silently + overwriting the entry. + """ + if not host: + return False + known_hosts_path.parent.mkdir(parents=True, exist_ok=True) + host_key = _fetch_server_key(host, port) + host_alias = _host_alias(host, port) + host_keys = paramiko.HostKeys() + if known_hosts_path.exists(): + host_keys.load(str(known_hosts_path)) + + existing = host_keys.lookup(host_alias) + key_type = host_key.get_name() + if existing and key_type in existing: + if existing[key_type].get_base64() == host_key.get_base64(): + return False + if strict: + raise RuntimeError(f"SSH host key mismatch for {host_alias}") + + host_keys.add(host_alias, key_type, host_key) + host_keys.save(str(known_hosts_path)) + logger.info("Recorded SSH host key host=%s port=%s file=%s", host, port, known_hosts_path) + return True + + +def ensure_known_hosts_for_machines( + machines: list[dict[str, Any]], + known_hosts_path: Path, + *, + strict: bool = True, +) -> int: + changed = 0 + for machine in machines: + if str(machine.get("mode") or "").lower() != "ssh": + continue + host = str(machine.get("host") or "").strip() + port = int(machine.get("port") or 22) + if not host: + continue + if ensure_known_host(host, port, known_hosts_path, strict=strict): + changed += 1 + return changed diff --git a/backend/src/media_library_viewer_api/services/monitoring_actions.py b/backend/src/media_library_viewer_api/services/monitoring_actions.py new file mode 100644 index 0000000..9dda1f0 --- /dev/null +++ b/backend/src/media_library_viewer_api/services/monitoring_actions.py @@ -0,0 +1,330 @@ +"""Shared monitoring action helpers. + +The router and the background poller both use these helpers so machine +operations are recorded consistently whether they were triggered by a user +request or by the backend's scheduled polling loop. +""" + +from __future__ import annotations + +import json +import logging +import time +import uuid +from typing import Any, Callable + +from fastapi import HTTPException + +from media_library_viewer_api.clients.local import LocalCommandClient +from media_library_viewer_api.clients.resources import ( + disk_space, + read_resource_metrics, + resource_collector_debug_info, + resource_collector_status, + restart_resource_collector, + start_resource_collector, + stop_resource_collector, +) +from media_library_viewer_api.clients.ssh import RemoteSSHClient +from media_library_viewer_api.config import get_settings +from media_library_viewer_api.services.settings_store import SettingsStore + +logger = logging.getLogger(__name__) + + +def build_machine_client(machine: dict[str, Any]): + """Build the appropriate command client for a machine definition.""" + mode = str(machine.get("mode") or "local").strip().lower() + if mode == "local": + return LocalCommandClient() + + key_directory = str(machine.get("key_directory") or "").strip() + key_name = str(machine.get("key_name") or "").strip() + key_path = f"{key_directory}/{key_name}" if key_directory and key_name else None + return RemoteSSHClient( + host=str(machine.get("host") or ""), + username=str(machine.get("username") or ""), + port=int(machine.get("port") or 22), + key_filename=key_path, + password=str(machine.get("password") or "") or None, + ) + + +def summarize_operation_result(action: str, result: Any) -> str: + """Turn an operation result into a compact human-readable summary.""" + if result is None: + return action + if isinstance(result, str): + text = result.strip().splitlines()[0] if result.strip() else action + return text[:200] + if isinstance(result, list): + return f"{action}: {len(result)} item(s)" + if isinstance(result, dict): + if action == "metrics read": + samples = result.get("samples") + if isinstance(samples, list): + return f"{action}: {len(samples)} sample(s)" + if action.startswith("disk lookup"): + used_pct = result.get("used_pct") + mount = result.get("mount") or result.get("filesystem") + return f"disk {mount or ''} used {used_pct or '?'}".strip() + if "message" in result and isinstance(result["message"], str): + return result["message"][:200] + return json_compact(result) + return action + + +def json_compact(value: Any) -> str: + try: + text = json.dumps(value, sort_keys=True, default=str) + return text[:200] + except Exception: + return str(value)[:200] + + +def run_machine_operation( + machine: dict[str, Any], + store: SettingsStore, + action: str, + callback: Callable[[Any], Any], + *, + summarize: Callable[[Any], str] | None = None, + request_id: str = "", + raise_http: bool = True, +) -> Any: + """Run a machine operation, record history, and optionally raise on failure.""" + started = time.perf_counter() + client = build_machine_client(machine) + try: + result = callback(client) + duration_ms = int((time.perf_counter() - started) * 1000) + store.record_machine_action( + machine, + action, + "ok", + duration_ms=duration_ms, + request_id=request_id, + message=(summarize(result) if summarize else summarize_operation_result(action, result)), + ) + return result + except HTTPException: + raise + except Exception as exc: # pragma: no cover - transport/network fallback + duration_ms = int((time.perf_counter() - started) * 1000) + logger.exception( + "Monitoring %s failed machine_id=%s machine_name=%s", + action, + machine["id"], + machine["name"], + ) + error_text = str(exc) + store.record_machine_action( + machine, + action, + "error", + duration_ms=duration_ms, + request_id=request_id, + error=error_text, + ) + if not raise_http: + return None + status_code = 503 if machine.get("mode") == "local" else 502 + raise HTTPException( + status_code=status_code, + detail=f"{machine['name']}: {action} failed: {exc}", + ) from exc + + +def poll_machine_snapshot( + machine: dict[str, Any], + store: SettingsStore, + *, + metrics_limit: int = 70_000, + request_id: str | None = None, +) -> dict[str, Any]: + """Collect a backend-scheduled snapshot for a machine. + + This records the same action-history rows that the UI would otherwise get + from manual requests, but it runs entirely inside the backend on a schedule. + """ + request_id = request_id or f"poll:{machine.get('id') or uuid.uuid4().hex}" + results: dict[str, Any] = {"request_id": request_id, "machine_id": machine.get("id"), "actions": []} + + status = run_machine_operation( + machine, + store, + "status lookup", + resource_collector_status, + request_id=request_id, + raise_http=False, + ) + results["status"] = status + results["actions"].append("status lookup") + + metrics = run_machine_operation( + machine, + store, + "metrics read", + lambda client: read_resource_metrics(client, max_lines=metrics_limit), + request_id=request_id, + raise_http=False, + ) + results["metrics_samples"] = len(metrics or []) + results["actions"].append("metrics read") + + settings = get_settings() + path = str(machine.get("media_root") or settings.media_root or "/") + disk = run_machine_operation( + machine, + store, + f"disk lookup for {path}", + lambda client: disk_space(client, path), + request_id=request_id, + raise_http=False, + ) + results["disk_mount"] = (disk or {}).get("mount") if isinstance(disk, dict) else None + results["actions"].append("disk lookup") + + return results + + +def _summarize_metric_samples(samples: list[dict[str, Any]], field: str) -> dict[str, float] | None: + values = [float(sample.get(field, 0)) for sample in samples if sample.get(field) is not None] + if not values: + return None + return { + "avg": sum(values) / len(values), + "min": min(values), + "max": max(values), + "count": float(len(values)), + } + + +def collect_machine_overview(machine: dict[str, Any], *, metrics_window_seconds: int = 600, metrics_limit: int = 70_000) -> dict[str, Any]: + """Collect a lightweight machine overview without recording history.""" + overview: dict[str, Any] = { + "machine": {k: machine.get(k) for k in ("id", "name", "mode", "enabled", "host", "port", "username", "media_root", "path_prefix", "notes")}, + "status": "", + "status_error": "", + "metrics_error": "", + "disk_error": "", + "sample_count": 0, + "latest_sample": None, + "cpu_summary": None, + "iowait_summary": None, + "mem_summary": None, + "net_rx_summary": None, + "net_tx_summary": None, + "disk_read_summary": None, + "disk_write_summary": None, + "disk": None, + } + try: + client: Any = build_machine_client(machine) + except Exception as exc: + overview["status_error"] = str(exc) + overview["metrics_error"] = str(exc) + overview["disk_error"] = str(exc) + return overview + + settings = get_settings() + path = str(machine.get("media_root") or settings.media_root or "/") + + try: + overview["status"] = resource_collector_status(client) + except Exception as exc: + overview["status_error"] = str(exc) + + try: + samples = read_resource_metrics(client, max_lines=metrics_limit) + if samples: + latest_ts = max(float(sample.get("ts", 0)) for sample in samples) + window_start = latest_ts - metrics_window_seconds + metrics = [sample for sample in samples if float(sample.get("ts", 0)) >= window_start] + if not metrics: + metrics = samples + overview["sample_count"] = len(metrics) + overview["latest_sample"] = metrics[-1] + overview["cpu_summary"] = _summarize_metric_samples(metrics, "cpu_pct") + overview["iowait_summary"] = _summarize_metric_samples(metrics, "iowait_pct") + overview["mem_summary"] = _summarize_metric_samples(metrics, "mem_pct") + overview["net_rx_summary"] = _summarize_metric_samples(metrics, "net_rx_bytes_per_sec") + overview["net_tx_summary"] = _summarize_metric_samples(metrics, "net_tx_bytes_per_sec") + overview["disk_read_summary"] = _summarize_metric_samples(metrics, "disk_read_bps") + overview["disk_write_summary"] = _summarize_metric_samples(metrics, "disk_write_bps") + except Exception as exc: + overview["metrics_error"] = str(exc) + + try: + overview["disk"] = disk_space(client, path) + except Exception as exc: + overview["disk_error"] = str(exc) + + return overview + + +def poll_machine_diagnostics( + machine: dict[str, Any], + store: SettingsStore, + *, + request_id: str | None = None, +) -> dict[str, Any]: + """Collect a backend-scheduled diagnostics snapshot for a machine.""" + request_id = request_id or f"poll:{machine.get('id') or uuid.uuid4().hex}" + diagnostics = run_machine_operation( + machine, + store, + "collector diagnostics", + resource_collector_debug_info, + request_id=request_id, + raise_http=False, + ) + return {"request_id": request_id, "diagnostics": diagnostics} + + +def start_collector( + machine: dict[str, Any], + store: SettingsStore, + *, + request_id: str = "", +) -> Any: + return run_machine_operation( + machine, + store, + "collector start", + start_resource_collector, + request_id=request_id, + raise_http=True, + ) + + +def stop_collector( + machine: dict[str, Any], + store: SettingsStore, + *, + request_id: str = "", +) -> Any: + return run_machine_operation( + machine, + store, + "collector stop", + stop_resource_collector, + request_id=request_id, + raise_http=True, + ) + + +def restart_collector( + machine: dict[str, Any], + store: SettingsStore, + *, + request_id: str = "", +) -> Any: + return run_machine_operation( + machine, + store, + "collector restart", + restart_resource_collector, + request_id=request_id, + raise_http=True, + ) diff --git a/backend/src/media_library_viewer_api/services/monitoring_poller.py b/backend/src/media_library_viewer_api/services/monitoring_poller.py new file mode 100644 index 0000000..23dce06 --- /dev/null +++ b/backend/src/media_library_viewer_api/services/monitoring_poller.py @@ -0,0 +1,184 @@ +"""Background poller for monitoring machine snapshots. + +The poller runs entirely inside the backend. It periodically reads the defined +machines, collects a small snapshot from each enabled machine over SSH or local +shell execution, and stores the resulting history rows in the settings DB. + +This keeps the Monitoring page populated without any daemon or agent running on +the remote machines. +""" + +from __future__ import annotations + +import logging +import threading +import time +from dataclasses import dataclass +from typing import Any + +from media_library_viewer_api.config import get_settings +from media_library_viewer_api.services.monitoring_actions import poll_machine_snapshot +from media_library_viewer_api.services.settings_store import SettingsStore, get_settings_store + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class PollerConfig: + interval_seconds: int = 300 + initial_delay_seconds: int = 20 + metrics_limit: int = 70_000 + retention_days: int = 30 + + +class MonitoringPoller: + """Single-worker background poller for monitoring snapshots.""" + + def __init__(self) -> None: + self._thread: threading.Thread | None = None + self._stop_event = threading.Event() + self._lock = threading.Lock() + self._last_run_at: float | None = None + self._last_success_at: float | None = None + self._last_error: str = "" + self._last_cycle_ms: int | None = None + self._poll_count = 0 + self._error_count = 0 + + def _config(self) -> PollerConfig: + settings = get_settings() + return PollerConfig( + interval_seconds=max(30, int(getattr(settings, "monitoring_poll_interval_seconds", 300) or 300)), + initial_delay_seconds=max(0, int(getattr(settings, "monitoring_poll_initial_delay_seconds", 20) or 20)), + metrics_limit=70_000, + retention_days=max(1, int(getattr(settings, "monitoring_action_retention_days", 30) or 30)), + ) + + def start(self) -> None: + """Start the background worker if it is not already running.""" + with self._lock: + if self._thread and self._thread.is_alive(): + return + self._stop_event.clear() + self._thread = threading.Thread(target=self._run, name="monitoring-poller", daemon=True) + self._thread.start() + logger.info("Monitoring poller started") + + def stop(self, timeout: float = 5.0) -> None: + """Stop the worker thread and wait briefly for shutdown.""" + with self._lock: + thread = self._thread + if not thread: + return + self._stop_event.set() + thread.join(timeout=timeout) + if thread.is_alive(): + logger.warning("Monitoring poller did not stop within %.1fs", timeout) + else: + logger.info("Monitoring poller stopped") + with self._lock: + if self._thread is thread: + self._thread = None + + def status(self) -> dict[str, Any]: + """Return a small status snapshot for diagnostics and tests.""" + with self._lock: + return { + "worker_running": bool(self._thread and self._thread.is_alive()), + "stop_requested": self._stop_event.is_set(), + "last_run_at": self._last_run_at, + "last_success_at": self._last_success_at, + "last_error": self._last_error, + "last_cycle_ms": self._last_cycle_ms, + "poll_count": self._poll_count, + "error_count": self._error_count, + } + + def snapshot(self) -> dict[str, Any]: + """Return status plus the active polling configuration.""" + data = self.status() + config = self._config() + data.update( + { + "interval_seconds": config.interval_seconds, + "initial_delay_seconds": config.initial_delay_seconds, + "retention_days": config.retention_days, + } + ) + return data + + def _run_cycle(self, store: SettingsStore, config: PollerConfig) -> None: + start = time.perf_counter() + machines = store.list_machines() + enabled = [machine for machine in machines if machine.get("enabled")] + logger.info("Monitoring poll cycle starting enabled_machines=%s", len(enabled)) + cycle_errors = 0 + for machine in enabled: + if self._stop_event.is_set(): + break + try: + snapshot = poll_machine_snapshot( + machine, + store, + metrics_limit=config.metrics_limit, + request_id=f"poll:{machine['id']}:{int(time.time())}", + ) + logger.info( + "Monitoring poll snapshot machine_id=%s request_id=%s status=%s metrics_samples=%s disk_mount=%s", + machine["id"], + snapshot.get("request_id"), + snapshot.get("status"), + snapshot.get("metrics_samples"), + snapshot.get("disk_mount"), + ) + except Exception: + cycle_errors += 1 + logger.exception("Monitoring poll snapshot failed machine_id=%s machine_name=%s", machine["id"], machine["name"]) + retention_seconds = config.retention_days * 24 * 60 * 60 + cutoff_ts = int(time.time()) - retention_seconds + removed = store.prune_machine_actions(cutoff_ts) + if removed: + logger.info("Pruned %s old monitoring action rows older than %s", removed, cutoff_ts) + duration_ms = int((time.perf_counter() - start) * 1000) + with self._lock: + self._last_run_at = time.time() + self._last_cycle_ms = duration_ms + self._poll_count += 1 + if cycle_errors: + self._error_count += cycle_errors + self._last_error = f"{cycle_errors} machine(s) failed" + else: + self._last_success_at = self._last_run_at + self._last_error = "" + logger.info( + "Monitoring poll cycle complete enabled_machines=%s errors=%s duration_ms=%s removed_rows=%s", + len(enabled), + cycle_errors, + duration_ms, + removed, + ) + + def _run(self) -> None: + config = self._config() + if config.initial_delay_seconds: + logger.info("Monitoring poller initial delay=%ss", config.initial_delay_seconds) + if self._stop_event.wait(config.initial_delay_seconds): + return + store = get_settings_store() + while not self._stop_event.is_set(): + try: + self._run_cycle(store, config) + except Exception: + with self._lock: + self._last_error = "poller cycle failed" + self._error_count += 1 + logger.exception("Monitoring poller cycle failed") + if self._stop_event.wait(config.interval_seconds): + break + + +_MONITORING_POLLER = MonitoringPoller() + + +def get_monitoring_poller() -> MonitoringPoller: + return _MONITORING_POLLER diff --git a/backend/src/media_library_viewer_api/services/settings_store.py b/backend/src/media_library_viewer_api/services/settings_store.py new file mode 100644 index 0000000..0e6052e --- /dev/null +++ b/backend/src/media_library_viewer_api/services/settings_store.py @@ -0,0 +1,748 @@ +"""Persistent application settings stored in a small SQLite database. + +The store manages machine definitions, machine services, and per-machine +application configuration so the frontend can present local and remote targets +in the same UI. +""" + +from __future__ import annotations + +import json +import sqlite3 +from io import StringIO +import time +import uuid +from pathlib import Path +from typing import Any + +import paramiko +from media_library_viewer_api.config import get_settings + +DEFAULT_SETTINGS_PATH = Path(".cache/media_library_viewer/settings.sqlite") +LOCAL_MACHINE_ID = "local" +DEFAULT_SERVICES = ["monitoring", "files", "jellyfin"] + + +def _default_local_machine() -> dict[str, Any]: + settings = get_settings() + return { + "id": LOCAL_MACHINE_ID, + "name": "This machine", + "mode": "local", + "enabled": True, + "services": list(DEFAULT_SERVICES), + "host": "", + "port": 22, + "username": "", + "key_directory": "", + "key_name": "", + "ssh_key_id": "", + "ssh_private_key_set": False, + "ssh_private_key_passphrase_set": False, + "password_set": False, + "media_root": settings.media_root, + "path_prefix": settings.path_prefix, + "jellyfin_url": settings.jellyfin_url, + "jellyfin_user_id": settings.jellyfin_user_id, + "jellyfin_api_key_set": bool(settings.jellyfin_api_key), + "jellyseerr_url": settings.jellyseerr_url, + "jellyseerr_api_key_set": bool(settings.jellyseerr_api_key), + "notes": "", + } + + +class SettingsStore: + """SQLite-backed settings store for machine definitions and history.""" + + def __init__(self, db_path: Path | str = DEFAULT_SETTINGS_PATH): + self.db_path = Path(db_path) + self.db_path.parent.mkdir(parents=True, exist_ok=True) + + def connect(self) -> sqlite3.Connection: + conn = sqlite3.connect(self.db_path, timeout=30) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA busy_timeout=30000") + return conn + + def init_schema(self) -> None: + with self.connect() as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS monitoring_machines ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + mode TEXT NOT NULL, + enabled INTEGER NOT NULL, + config_json TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_monitoring_machines_mode ON monitoring_machines(mode)" + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS monitoring_machine_actions ( + id TEXT PRIMARY KEY, + machine_id TEXT NOT NULL, + machine_name TEXT NOT NULL, + mode TEXT NOT NULL, + action TEXT NOT NULL, + status TEXT NOT NULL, + created_at INTEGER NOT NULL, + duration_ms INTEGER NOT NULL, + request_id TEXT NOT NULL, + message TEXT NOT NULL, + error TEXT NOT NULL, + stdout_tail TEXT NOT NULL, + stderr_tail TEXT NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS ssh_keys ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + private_key TEXT NOT NULL, + passphrase TEXT NOT NULL, + notes TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS saved_tasks ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + task_type TEXT NOT NULL, + content TEXT NOT NULL, + enabled INTEGER NOT NULL, + default_machine_id TEXT NOT NULL, + notes TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_saved_tasks_name ON saved_tasks(name)" + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS saved_task_runs ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + task_name TEXT NOT NULL, + machine_id TEXT NOT NULL, + machine_name TEXT NOT NULL, + task_type TEXT NOT NULL, + status TEXT NOT NULL, + created_at INTEGER NOT NULL, + duration_ms INTEGER NOT NULL, + request_id TEXT NOT NULL, + stdout_tail TEXT NOT NULL, + stderr_tail TEXT NOT NULL, + error TEXT NOT NULL + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_saved_task_runs_task_time ON saved_task_runs(task_id, created_at DESC)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_monitoring_machine_actions_machine_time ON monitoring_machine_actions(machine_id, created_at DESC)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_monitoring_machine_actions_action_status ON monitoring_machine_actions(action, status)" + ) + + @staticmethod + def _normalize_services(value: Any, fallback: list[str] | None = None) -> list[str]: + if isinstance(value, str): + items = [part.strip() for part in value.split(",")] + elif isinstance(value, list): + items = [str(part).strip() for part in value] + else: + items = list(fallback or DEFAULT_SERVICES) + services = [item for item in items if item] + if not services: + services = list(fallback or DEFAULT_SERVICES) + deduped: list[str] = [] + for service in services: + if service not in deduped: + deduped.append(service) + return deduped + + def _row_to_machine(self, row: sqlite3.Row) -> dict[str, Any]: + data = json.loads(row["config_json"]) + services = self._normalize_services(data.get("services"), DEFAULT_SERVICES if row["id"] == LOCAL_MACHINE_ID else []) + return { + "id": row["id"], + "name": row["name"], + "mode": row["mode"], + "enabled": bool(row["enabled"]), + "services": services, + "host": data.get("host", ""), + "port": int(data.get("port", 22) or 22), + "username": data.get("username", ""), + "key_directory": data.get("key_directory", ""), + "key_name": data.get("key_name", ""), + "ssh_key_id": data.get("ssh_key_id", ""), + "ssh_private_key_set": bool(data.get("ssh_private_key")), + "ssh_private_key_passphrase_set": bool(data.get("ssh_private_key_passphrase")), + "password_set": bool(data.get("password")), + "media_root": data.get("media_root", ""), + "path_prefix": data.get("path_prefix", ""), + "jellyfin_url": data.get("jellyfin_url", ""), + "jellyfin_user_id": data.get("jellyfin_user_id", ""), + "jellyfin_api_key_set": bool(data.get("jellyfin_api_key")), + "jellyseerr_url": data.get("jellyseerr_url", ""), + "jellyseerr_api_key_set": bool(data.get("jellyseerr_api_key")), + "notes": data.get("notes", ""), + "created_at": row["created_at"], + "updated_at": row["updated_at"], + } + + def _normalize_machine_payload( + self, + payload: dict[str, Any], + machine_id: str | None = None, + ) -> dict[str, Any]: + current = self.get_machine(machine_id) if machine_id else None + machine_id = str(payload.get("id") or machine_id or uuid.uuid4().hex[:12]).strip() or uuid.uuid4().hex[:12] + mode = str(payload.get("mode") or (current or {}).get("mode") or "local").strip().lower() + if mode not in {"local", "ssh"}: + mode = "local" + enabled = bool(payload.get("enabled", (current or {}).get("enabled", True))) + name = str(payload.get("name") or (current or {}).get("name") or "").strip() or ( + "This machine" if mode == "local" else machine_id + ) + services = self._normalize_services(payload.get("services"), (current or {}).get("services", [])) + host = str(payload.get("host") if payload.get("host") is not None else (current or {}).get("host", "") or "").strip() + port = int(payload.get("port") or (current or {}).get("port", 22) or 22) + username = str(payload.get("username") if payload.get("username") is not None else (current or {}).get("username", "") or "").strip() + key_directory = str(payload.get("key_directory") if payload.get("key_directory") is not None else (current or {}).get("key_directory", "") or "").strip() + key_name = str(payload.get("key_name") if payload.get("key_name") is not None else (current or {}).get("key_name", "") or "").strip() + ssh_key_id = str(payload.get("ssh_key_id") if payload.get("ssh_key_id") is not None else (current or {}).get("ssh_key_id", "") or "").strip() + ssh_private_key = payload.get("ssh_private_key") + if ssh_private_key in (None, ""): + ssh_private_key = (current or {}).get("ssh_private_key", "") + ssh_private_key = str(ssh_private_key or "") + ssh_private_key_passphrase = payload.get("ssh_private_key_passphrase") + if ssh_private_key_passphrase in (None, ""): + ssh_private_key_passphrase = (current or {}).get("ssh_private_key_passphrase", "") + ssh_private_key_passphrase = str(ssh_private_key_passphrase or "") + password = payload.get("password") + if password in (None, ""): + password = (current or {}).get("password", "") + password = str(password or "") + media_root = str(payload.get("media_root") if payload.get("media_root") is not None else (current or {}).get("media_root", "") or "").strip() + path_prefix = str(payload.get("path_prefix") if payload.get("path_prefix") is not None else (current or {}).get("path_prefix", "") or "").strip() + jellyfin_url = str(payload.get("jellyfin_url") if payload.get("jellyfin_url") is not None else (current or {}).get("jellyfin_url", "") or "").strip() + jellyfin_user_id = str(payload.get("jellyfin_user_id") if payload.get("jellyfin_user_id") is not None else (current or {}).get("jellyfin_user_id", "") or "").strip() + jellyfin_api_key = payload.get("jellyfin_api_key") + if jellyfin_api_key in (None, ""): + jellyfin_api_key = (current or {}).get("jellyfin_api_key", "") + jellyfin_api_key = str(jellyfin_api_key or "") + jellyseerr_url = str(payload.get("jellyseerr_url") if payload.get("jellyseerr_url") is not None else (current or {}).get("jellyseerr_url", "") or "").strip() + jellyseerr_api_key = payload.get("jellyseerr_api_key") + if jellyseerr_api_key in (None, ""): + jellyseerr_api_key = (current or {}).get("jellyseerr_api_key", "") + jellyseerr_api_key = str(jellyseerr_api_key or "") + notes = str(payload.get("notes") if payload.get("notes") is not None else (current or {}).get("notes", "") or "").strip() + if mode == "local": + host = host or "localhost" + username = username or "" + return { + "id": machine_id, + "name": name, + "mode": mode, + "enabled": enabled, + "services": services, + "host": host, + "port": port, + "username": username, + "key_directory": key_directory, + "key_name": key_name, + "ssh_key_id": ssh_key_id, + "ssh_private_key": ssh_private_key, + "ssh_private_key_passphrase": ssh_private_key_passphrase, + "password": password, + "media_root": media_root, + "path_prefix": path_prefix, + "jellyfin_url": jellyfin_url, + "jellyfin_user_id": jellyfin_user_id, + "jellyfin_api_key": jellyfin_api_key, + "jellyseerr_url": jellyseerr_url, + "jellyseerr_api_key": jellyseerr_api_key, + "notes": notes, + } + + def ensure_defaults(self) -> None: + self.init_schema() + with self.connect() as conn: + row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone() + if row and int(row[0]) > 0: + return + machine = _default_local_machine() + now = int(time.time()) + config = { + "services": machine["services"], + "host": machine["host"], + "port": machine["port"], + "username": machine["username"], + "key_directory": machine["key_directory"], + "key_name": machine["key_name"], + "ssh_key_id": machine.get("ssh_key_id", ""), + "ssh_private_key": "", + "ssh_private_key_passphrase": "", + "password": "", + "media_root": machine["media_root"], + "path_prefix": machine["path_prefix"], + "jellyfin_url": machine["jellyfin_url"], + "jellyfin_user_id": machine["jellyfin_user_id"], + "jellyfin_api_key": "", + "jellyseerr_url": machine["jellyseerr_url"], + "jellyseerr_api_key": "", + "notes": machine["notes"], + } + conn.execute( + """ + INSERT INTO monitoring_machines (id, name, mode, enabled, config_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + machine["id"], + machine["name"], + machine["mode"], + 1, + json.dumps(config), + now, + now, + ), + ) + + def list_machines(self) -> list[dict[str, Any]]: + self.ensure_defaults() + with self.connect() as conn: + rows = conn.execute( + "SELECT * FROM monitoring_machines ORDER BY CASE WHEN id = ? THEN 0 ELSE 1 END, name COLLATE NOCASE", + (LOCAL_MACHINE_ID,), + ).fetchall() + return [self._row_to_machine(row) for row in rows] + + def get_machine(self, machine_id: str | None) -> dict[str, Any] | None: + if not machine_id: + return None + self.ensure_defaults() + with self.connect() as conn: + row = conn.execute("SELECT * FROM monitoring_machines WHERE id = ?", (machine_id,)).fetchone() + return self._row_to_machine(row) if row else None + + def get_machine_config(self, machine_id: str | None) -> dict[str, Any] | None: + """Return the full machine config including secrets.""" + if not machine_id: + return None + self.ensure_defaults() + with self.connect() as conn: + row = conn.execute("SELECT * FROM monitoring_machines WHERE id = ?", (machine_id,)).fetchone() + if not row: + return None + data = json.loads(row["config_json"]) + return { + "id": row["id"], + "name": row["name"], + "mode": row["mode"], + "enabled": bool(row["enabled"]), + "services": self._normalize_services(data.get("services"), DEFAULT_SERVICES if row["id"] == LOCAL_MACHINE_ID else []), + "host": data.get("host", ""), + "port": int(data.get("port", 22) or 22), + "username": data.get("username", ""), + "key_directory": data.get("key_directory", ""), + "key_name": data.get("key_name", ""), + "ssh_key_id": data.get("ssh_key_id", ""), + "ssh_private_key": data.get("ssh_private_key", ""), + "ssh_private_key_passphrase": data.get("ssh_private_key_passphrase", ""), + "password": data.get("password", ""), + "media_root": data.get("media_root", ""), + "path_prefix": data.get("path_prefix", ""), + "jellyfin_url": data.get("jellyfin_url", ""), + "jellyfin_user_id": data.get("jellyfin_user_id", ""), + "jellyfin_api_key": data.get("jellyfin_api_key", ""), + "jellyseerr_url": data.get("jellyseerr_url", ""), + "jellyseerr_api_key": data.get("jellyseerr_api_key", ""), + "notes": data.get("notes", ""), + } + + def list_machines_for_service(self, service: str) -> list[dict[str, Any]]: + return [machine for machine in self.list_machines() if service in machine.get("services", []) and machine.get("enabled")] + + def get_machine_for_service(self, service: str, machine_id: str | None = None) -> dict[str, Any] | None: + if machine_id: + machine = self.get_machine(machine_id) + if machine and service in machine.get("services", []) and machine.get("enabled"): + return machine + return machine if machine else None + machines = self.list_machines_for_service(service) + return machines[0] if machines else None + + def upsert_machine(self, payload: dict[str, Any], machine_id: str | None = None) -> dict[str, Any]: + self.init_schema() + machine = self._normalize_machine_payload(payload, machine_id) + now = int(time.time()) + config = { + "services": machine["services"], + "host": machine["host"], + "port": machine["port"], + "username": machine["username"], + "key_directory": machine["key_directory"], + "key_name": machine["key_name"], + "ssh_key_id": machine.get("ssh_key_id", ""), + "ssh_private_key": machine["ssh_private_key"], + "ssh_private_key_passphrase": machine["ssh_private_key_passphrase"], + "password": machine["password"], + "media_root": machine["media_root"], + "path_prefix": machine["path_prefix"], + "jellyfin_url": machine["jellyfin_url"], + "jellyfin_user_id": machine["jellyfin_user_id"], + "jellyfin_api_key": machine["jellyfin_api_key"], + "jellyseerr_url": machine["jellyseerr_url"], + "jellyseerr_api_key": machine["jellyseerr_api_key"], + "notes": machine["notes"], + } + with self.connect() as conn: + existing = conn.execute("SELECT created_at FROM monitoring_machines WHERE id = ?", (machine["id"],)).fetchone() + created_at = int(existing[0]) if existing else now + conn.execute( + """ + INSERT INTO monitoring_machines (id, name, mode, enabled, config_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + mode = excluded.mode, + enabled = excluded.enabled, + config_json = excluded.config_json, + updated_at = excluded.updated_at + """, + ( + machine["id"], + machine["name"], + machine["mode"], + 1 if machine["enabled"] else 0, + json.dumps(config), + created_at, + now, + ), + ) + return self.get_machine(machine["id"]) or machine + + def delete_machine(self, machine_id: str) -> None: + self.init_schema() + with self.connect() as conn: + conn.execute("DELETE FROM monitoring_machines WHERE id = ?", (machine_id,)) + + def record_machine_action( + self, + machine: dict[str, Any], + action: str, + status: str, + *, + duration_ms: int, + request_id: str = "", + message: str = "", + error: str = "", + stdout_tail: str = "", + stderr_tail: str = "", + ) -> None: + """Store a compact action history row for a machine operation.""" + self.init_schema() + now = int(time.time()) + with self.connect() as conn: + conn.execute( + """ + INSERT INTO monitoring_machine_actions + (id, machine_id, machine_name, mode, action, status, created_at, duration_ms, request_id, message, error, stdout_tail, stderr_tail) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """ + , + ( + uuid.uuid4().hex, + str(machine.get("id") or ""), + str(machine.get("name") or ""), + str(machine.get("mode") or "local"), + action, + status, + now, + duration_ms, + request_id, + message, + error, + stdout_tail, + stderr_tail, + ), + ) + + def list_machine_actions( + self, + machine_id: str, + *, + limit: int = 20, + action: str | None = None, + status: str | None = None, + ) -> list[dict[str, Any]]: + self.init_schema() + clauses = ["machine_id = ?"] + params: list[Any] = [machine_id] + if action: + clauses.append("action = ?") + params.append(action) + if status: + clauses.append("status = ?") + params.append(status) + sql = ( + "SELECT machine_id, machine_name, mode, action, status, created_at, duration_ms, request_id, message, error, stdout_tail, stderr_tail " + f"FROM monitoring_machine_actions WHERE {' AND '.join(clauses)} ORDER BY created_at DESC LIMIT ?" + ) + params.append(max(1, min(int(limit), 200))) + with self.connect() as conn: + rows = conn.execute(sql, params).fetchall() + return [dict(row) for row in rows] + + def prune_machine_actions(self, older_than_ts: int) -> int: + """Delete action history rows older than the given timestamp.""" + self.init_schema() + with self.connect() as conn: + cur = conn.execute( + "DELETE FROM monitoring_machine_actions WHERE created_at < ?", + (int(older_than_ts),), + ) + return int(cur.rowcount or 0) + + + @staticmethod + def _private_key_summary(private_key: str) -> dict[str, str]: + if not private_key: + return {"public_key": "", "fingerprint": ""} + key_classes = [paramiko.Ed25519Key, paramiko.RSAKey, paramiko.ECDSAKey] + for key_class in key_classes: + try: + key = key_class.from_private_key(StringIO(private_key)) + fingerprint = ":".join(f"{b:02x}" for b in key.get_fingerprint()) + return {"public_key": f"{key.get_name()} {key.get_base64()}", "fingerprint": fingerprint} + except Exception: + continue + return {"public_key": "", "fingerprint": ""} + + def _row_to_ssh_key(self, row: sqlite3.Row, usage_count: int = 0) -> dict[str, Any]: + summary = self._private_key_summary(str(row["private_key"] or "")) + return { + "id": row["id"], + "name": row["name"], + "private_key_set": bool(row["private_key"]), + "passphrase_set": bool(row["passphrase"]), + "public_key": summary["public_key"], + "fingerprint": summary["fingerprint"], + "usage_count": usage_count, + "notes": row["notes"], + "created_at": row["created_at"], + "updated_at": row["updated_at"], + } + + def _normalize_ssh_key_payload(self, payload: dict[str, Any], key_id: str | None = None) -> dict[str, Any]: + current = self.get_ssh_key(key_id) if key_id else None + key_id = str(payload.get("id") or key_id or uuid.uuid4().hex[:12]).strip() or uuid.uuid4().hex[:12] + name = str(payload.get("name") or (current or {}).get("name") or key_id).strip() or key_id + private_key = payload.get("private_key") + if private_key in (None, ""): + private_key = (current or {}).get("private_key", "") + private_key = str(private_key or "") + passphrase = payload.get("passphrase") + if passphrase in (None, ""): + passphrase = (current or {}).get("passphrase", "") + passphrase = str(passphrase or "") + notes = str(payload.get("notes") if payload.get("notes") is not None else (current or {}).get("notes", "") or "").strip() + return {"id": key_id, "name": name, "private_key": private_key, "passphrase": passphrase, "notes": notes} + + def list_ssh_keys(self) -> list[dict[str, Any]]: + self.init_schema() + machines = self.list_machines() + usage_counts: dict[str, int] = {} + for machine in machines: + ssh_key_id = str(machine.get("ssh_key_id") or "").strip() + if ssh_key_id: + usage_counts[ssh_key_id] = usage_counts.get(ssh_key_id, 0) + 1 + with self.connect() as conn: + rows = conn.execute("SELECT * FROM ssh_keys ORDER BY name COLLATE NOCASE").fetchall() + return [self._row_to_ssh_key(row, usage_counts.get(row["id"], 0)) for row in rows] + + def get_ssh_key(self, key_id: str | None) -> dict[str, Any] | None: + if not key_id: + return None + self.init_schema() + with self.connect() as conn: + row = conn.execute("SELECT * FROM ssh_keys WHERE id = ?", (key_id,)).fetchone() + if not row: + return None + summary = self._private_key_summary(str(row["private_key"] or "")) + return {"id": row["id"], "name": row["name"], "private_key": row["private_key"], "passphrase": row["passphrase"], "notes": row["notes"], "public_key": summary["public_key"], "fingerprint": summary["fingerprint"]} + + def upsert_ssh_key(self, payload: dict[str, Any], key_id: str | None = None) -> dict[str, Any]: + self.init_schema() + key = self._normalize_ssh_key_payload(payload, key_id) + now = int(time.time()) + with self.connect() as conn: + existing = conn.execute("SELECT created_at FROM ssh_keys WHERE id = ?", (key["id"],)).fetchone() + created_at = int(existing[0]) if existing else now + conn.execute( + """ + INSERT INTO ssh_keys (id, name, private_key, passphrase, notes, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + private_key = excluded.private_key, + passphrase = excluded.passphrase, + notes = excluded.notes, + updated_at = excluded.updated_at + """, + (key["id"], key["name"], key["private_key"], key["passphrase"], key["notes"], created_at, now), + ) + return self.get_ssh_key(key["id"]) or key + + def delete_ssh_key(self, key_id: str) -> None: + self.init_schema() + with self.connect() as conn: + conn.execute("DELETE FROM ssh_keys WHERE id = ?", (key_id,)) + + + + def _row_to_task(self, row: sqlite3.Row) -> dict[str, Any]: + return { + "id": row["id"], + "name": row["name"], + "task_type": row["task_type"], + "content": row["content"], + "enabled": bool(row["enabled"]), + "default_machine_id": row["default_machine_id"], + "notes": row["notes"], + "created_at": row["created_at"], + "updated_at": row["updated_at"], + } + + def _normalize_task_payload(self, payload: dict[str, Any], task_id: str | None = None) -> dict[str, Any]: + current = self.get_task(task_id) if task_id else None + task_id = str(payload.get("id") or task_id or uuid.uuid4().hex[:12]).strip() or uuid.uuid4().hex[:12] + name = str(payload.get("name") or (current or {}).get("name") or task_id).strip() or task_id + task_type = str(payload.get("task_type") or (current or {}).get("task_type") or "shell").strip().lower() + if task_type not in {"shell", "python"}: + task_type = "shell" + content = str(payload.get("content") if payload.get("content") is not None else (current or {}).get("content", "") or "") + enabled = bool(payload.get("enabled", (current or {}).get("enabled", True))) + default_machine_id = str(payload.get("default_machine_id") if payload.get("default_machine_id") is not None else (current or {}).get("default_machine_id", "") or "").strip() + notes = str(payload.get("notes") if payload.get("notes") is not None else (current or {}).get("notes", "") or "").strip() + return {"id": task_id, "name": name, "task_type": task_type, "content": content, "enabled": enabled, "default_machine_id": default_machine_id, "notes": notes} + + def list_tasks(self) -> list[dict[str, Any]]: + self.init_schema() + with self.connect() as conn: + rows = conn.execute("SELECT * FROM saved_tasks ORDER BY name COLLATE NOCASE").fetchall() + return [self._row_to_task(row) for row in rows] + + def get_task(self, task_id: str | None) -> dict[str, Any] | None: + if not task_id: + return None + self.init_schema() + with self.connect() as conn: + row = conn.execute("SELECT * FROM saved_tasks WHERE id = ?", (task_id,)).fetchone() + return self._row_to_task(row) if row else None + + def upsert_task(self, payload: dict[str, Any], task_id: str | None = None) -> dict[str, Any]: + self.init_schema() + task = self._normalize_task_payload(payload, task_id) + now = int(time.time()) + with self.connect() as conn: + existing = conn.execute("SELECT created_at FROM saved_tasks WHERE id = ?", (task["id"],)).fetchone() + created_at = int(existing[0]) if existing else now + conn.execute( + """ + INSERT INTO saved_tasks (id, name, task_type, content, enabled, default_machine_id, notes, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + task_type = excluded.task_type, + content = excluded.content, + enabled = excluded.enabled, + default_machine_id = excluded.default_machine_id, + notes = excluded.notes, + updated_at = excluded.updated_at + """, + (task["id"], task["name"], task["task_type"], task["content"], 1 if task["enabled"] else 0, task["default_machine_id"], task["notes"], created_at, now), + ) + return self.get_task(task["id"]) or task + + def delete_task(self, task_id: str) -> None: + self.init_schema() + with self.connect() as conn: + conn.execute("DELETE FROM saved_tasks WHERE id = ?", (task_id,)) + + def list_task_runs(self, task_id: str, *, limit: int = 10) -> list[dict[str, Any]]: + self.init_schema() + with self.connect() as conn: + rows = conn.execute( + "SELECT * FROM saved_task_runs WHERE task_id = ? ORDER BY created_at DESC LIMIT ?", + (task_id, max(1, min(int(limit), 50))), + ).fetchall() + return [dict(row) for row in rows] + + def record_task_run( + self, + task: dict[str, Any], + status: str, + *, + machine_id: str, + machine_name: str, + task_type: str, + duration_ms: int, + request_id: str = "", + stdout_tail: str = "", + stderr_tail: str = "", + error: str = "", + ) -> None: + self.init_schema() + with self.connect() as conn: + conn.execute( + """ + INSERT INTO saved_task_runs (id, task_id, task_name, machine_id, machine_name, task_type, status, created_at, duration_ms, request_id, stdout_tail, stderr_tail, error) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + uuid.uuid4().hex, + str(task.get("id") or ""), + str(task.get("name") or ""), + machine_id, + machine_name, + task_type, + status, + int(time.time()), + duration_ms, + request_id, + stdout_tail, + stderr_tail, + error, + ), + ) + + +_store: SettingsStore | None = None + + +def get_settings_store() -> SettingsStore: + global _store + if _store is None: + _store = SettingsStore() + return _store diff --git a/backend/tests/test_monitoring_actions.py b/backend/tests/test_monitoring_actions.py new file mode 100644 index 0000000..5edcc2c --- /dev/null +++ b/backend/tests/test_monitoring_actions.py @@ -0,0 +1,46 @@ +from unittest.mock import MagicMock, patch + +from media_library_viewer_api.services.monitoring_actions import poll_machine_snapshot + + +def test_poll_machine_snapshot_records_history_entries(): + store = MagicMock() + machine = { + "id": "local", + "name": "This machine", + "mode": "local", + "media_root": "/srv/media", + } + + with ( + patch( + "media_library_viewer_api.services.monitoring_actions.build_machine_client", + return_value=object(), + ) as build_client, + patch( + "media_library_viewer_api.services.monitoring_actions.resource_collector_status", + return_value="running pid=123", + ) as status_fn, + patch( + "media_library_viewer_api.services.monitoring_actions.read_resource_metrics", + return_value=[{"ts": 1.0}, {"ts": 2.0}], + ) as metrics_fn, + patch( + "media_library_viewer_api.services.monitoring_actions.disk_space", + return_value={"mount": "/srv/media", "used_pct": "12.5%"}, + ) as disk_fn, + ): + result = poll_machine_snapshot(machine, store, metrics_limit=123, request_id="poll:test") + + assert result["request_id"] == "poll:test" + assert result["metrics_samples"] == 2 + assert result["disk_mount"] == "/srv/media" + build_client.assert_called_once_with(machine) + status_fn.assert_called_once() + metrics_fn.assert_called_once_with(build_client.return_value, max_lines=123) + disk_fn.assert_called_once_with(build_client.return_value, "/srv/media") + assert store.record_machine_action.call_count == 3 + recorded_actions = [call.args[1] for call in store.record_machine_action.call_args_list] + assert recorded_actions == ["status lookup", "metrics read", "disk lookup for /srv/media"] + assert all(call.kwargs["request_id"] == "poll:test" for call in store.record_machine_action.call_args_list) + assert all(call.args[2] == "ok" for call in store.record_machine_action.call_args_list) diff --git a/frontend/src/components/DialogFooter.tsx b/frontend/src/components/DialogFooter.tsx new file mode 100644 index 0000000..e7e14c6 --- /dev/null +++ b/frontend/src/components/DialogFooter.tsx @@ -0,0 +1,46 @@ +import type { ReactNode } from "react"; +import { Box, Button, DialogActions } from "@mui/material"; + +interface DialogFooterProps { + onCancel: () => void; + cancelLabel?: string; + onConfirm: () => void; + confirmLabel: string; + confirmBusyLabel?: string; + confirmDisabled?: boolean; + confirmColor?: "primary" | "error" | "warning" | "success" | "inherit"; + confirmVariant?: "contained" | "outlined" | "text"; + confirmStartIcon?: ReactNode; + secondaryAction?: ReactNode; +} + +export function DialogFooter({ + onCancel, + cancelLabel = "Cancel", + onConfirm, + confirmLabel, + confirmBusyLabel, + confirmDisabled, + confirmColor = "primary", + confirmVariant = "contained", + confirmStartIcon, + secondaryAction, +}: DialogFooterProps) { + return ( + + + + {secondaryAction} + + + + ); +} diff --git a/frontend/src/components/HoverEditButton.tsx b/frontend/src/components/HoverEditButton.tsx new file mode 100644 index 0000000..9e20dec --- /dev/null +++ b/frontend/src/components/HoverEditButton.tsx @@ -0,0 +1,32 @@ +import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; +import { IconButton } from "@mui/material"; + +interface HoverEditButtonProps { + onClick: () => void; + label?: string; +} + +export function HoverEditButton({ + onClick, + label = "Edit", +}: HoverEditButtonProps) { + return ( + e.stopPropagation()} + onClick={(e) => { + e.stopPropagation(); + onClick(); + }} + sx={{ + opacity: 0, + transition: "opacity 120ms ease", + color: "text.secondary", + }} + > + + + ); +} diff --git a/frontend/src/components/MachineMonitoringSection.tsx b/frontend/src/components/MachineMonitoringSection.tsx new file mode 100644 index 0000000..5c577b1 --- /dev/null +++ b/frontend/src/components/MachineMonitoringSection.tsx @@ -0,0 +1,354 @@ +import { useMemo, useState } from "react"; +import { + Alert, + Box, + Button, + Chip, + FormControl, + Grid, + InputLabel, + MenuItem, + Paper, + Select, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from "@mui/material"; +import { MetricCard } from "./MetricCard"; +import { MonitoringCharts } from "./MonitoringCharts"; +import type { MonitoringMachine } from "../types"; +import { + useCollectorControls, + useDiskSpace, + useMachineActions, + useMonitoringMetrics, + useMonitoringStatus, +} from "../hooks/useMonitoring"; + +function formatBytes(bytes: number): string { + if (!bytes || bytes === 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + let value = bytes; + let unitIdx = 0; + while (value >= 1000 && unitIdx < units.length - 1) { + value /= 1000; + unitIdx++; + } + return `${value.toFixed(1)} ${units[unitIdx]}`; +} + +function formatRate(bytes: number): string { + return `${formatBytes(bytes)}/s`; +} + +function avg(arr: number[]) { + return arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : 0; +} +function max(arr: number[]) { + return arr.length ? Math.max(...arr) : 0; +} + +function formatActionTime(epochSeconds: number): string { + return new Date(epochSeconds * 1000).toLocaleString(); +} + +function ErrorBanner({ label, error }: { label: string; error: unknown }) { + if (!error) return null; + return ( + + {label}: {String(error)} + + ); +} + +export function MachineMonitoringSection({ + machine, +}: { + machine: MonitoringMachine; +}) { + const statusQuery = useMonitoringStatus(machine.id, machine.enabled); + const metricsQuery = useMonitoringMetrics(machine.id, machine.enabled); + const diskQuery = useDiskSpace(machine.id, machine.enabled); + const { start, stop, restart } = useCollectorControls(machine.id); + const actionsQuery = useMachineActions(machine.id, machine.enabled); + const [actionFilter, setActionFilter] = useState("all"); + const [resultFilter, setResultFilter] = useState("all"); + + const status = statusQuery.data; + const metrics = metricsQuery.data; + const disk = diskQuery.data; + const samples = metrics?.samples ?? []; + const latest = samples.at(-1); + const cpuArr = samples.map((s) => s.cpu_pct); + const iowArr = samples.map((s) => s.iowait_pct ?? 0); + const memArr = samples.map((s) => s.mem_pct); + const netDownArr = samples.map((s) => s.net_rx_bytes_per_sec); + const netUpArr = samples.map((s) => s.net_tx_bytes_per_sec); + const diskReadArr = samples.map((s) => s.disk_read_bps); + const diskWriteArr = samples.map((s) => s.disk_write_bps); + const actions = actionsQuery.data?.items ?? []; + const visibleActions = useMemo( + () => + actions.filter((action) => { + const actionMatches = + actionFilter === "all" || action.action === actionFilter; + const resultMatches = + resultFilter === "all" || action.status === resultFilter; + return actionMatches && resultMatches; + }), + [actions, actionFilter, resultFilter], + ); + const hasQueryError = + statusQuery.error || + metricsQuery.error || + diskQuery.error || + actionsQuery.error; + + return ( + + + + + {machine.name} + + + {machine.mode === "local" + ? "Local API host" + : `${machine.username || "user"}@${machine.host || "host"}:${machine.port}`} + {machine.media_root ? ` · media root ${machine.media_root}` : ""} + + + + + + + + + + + {hasQueryError && ( + + + + + + + )} + + + + + + + + + + + + + + + + + + + + + + + + + + {disk && ( + + + + + + + + + + + + + + + )} + + + + + + + + + + Recent activity + + + Collected automatically by the backend poller. + + + + + Action + + + + Result + + + + {actionsQuery.error && ( + + Recent activity: {String(actionsQuery.error)} + + )} + + + + + Time + Action + Status + Duration + Message + + + + {visibleActions.length === 0 ? ( + + + + No activity matches the current filters. + + + + ) : ( + visibleActions.map((action) => ( + + {formatActionTime(action.created_at)} + {action.action} + + + + {action.duration_ms} ms + + {action.message || action.error || "-"} + + + )) + )} + +
+
+
+
+ ); +} diff --git a/frontend/src/components/MonitoringOverviewTable.tsx b/frontend/src/components/MonitoringOverviewTable.tsx new file mode 100644 index 0000000..820af0b --- /dev/null +++ b/frontend/src/components/MonitoringOverviewTable.tsx @@ -0,0 +1,517 @@ +import { useMemo, useState } from "react"; +import { + Box, + Chip, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TableSortLabel, + Typography, +} from "@mui/material"; +import type { + MonitoringMachineOverview, + MonitoringOverviewResponse, +} from "../types"; + +type SortKey = + | "machine" + | "mode" + | "status" + | "cpu" + | "iowait" + | "mem" + | "net_rx" + | "net_tx" + | "disk_read" + | "disk_write" + | "disk_used" + | "updated"; + +function formatBytes(bytes: number): string { + if (!bytes || bytes === 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + let value = bytes; + let unitIdx = 0; + while (value >= 1000 && unitIdx < units.length - 1) { + value /= 1000; + unitIdx++; + } + return `${value.toFixed(1)} ${units[unitIdx]}`; +} + +function formatRate(bytes: number): string { + return `${formatBytes(bytes)}/s`; +} + +function formatTime(epochSeconds: number | null): string { + if (!epochSeconds) return "-"; + return new Date(epochSeconds * 1000).toLocaleString(); +} + +function formatAge(epochSeconds: number | null): string { + if (!epochSeconds) return "-"; + const diff = Date.now() / 1000 - epochSeconds; + if (diff < 60) return `${Math.max(0, Math.round(diff))}s ago`; + if (diff < 3600) return `${Math.round(diff / 60)}m ago`; + return `${Math.round(diff / 3600)}h ago`; +} + +function formatSummary( + summary: { avg: number; min: number; max: number } | null, + formatter: (value: number) => string, +) { + if (!summary) return { value: "-", subtext: "" }; + return { + value: formatter(summary.avg), + subtext: `Low ${formatter(summary.min)}\nHigh ${formatter(summary.max)}`, + }; +} + +function sortableText(value: string) { + return value.toLowerCase(); +} + +function metricSortValue( + row: MonitoringMachineOverview, + key: SortKey, +): number | string { + switch (key) { + case "machine": + return sortableText(row.machine.name); + case "mode": + return row.machine.mode; + case "status": + return sortableText(row.status || row.status_error || ""); + case "cpu": + return row.cpu_summary?.avg ?? -1; + case "iowait": + return row.iowait_summary?.avg ?? -1; + case "mem": + return row.mem_summary?.avg ?? -1; + case "net_rx": + return row.net_rx_summary?.avg ?? -1; + case "net_tx": + return row.net_tx_summary?.avg ?? -1; + case "disk_read": + return row.disk_read_summary?.avg ?? -1; + case "disk_write": + return row.disk_write_summary?.avg ?? -1; + case "disk_used": + return parseFloat((row.disk?.used_pct || "0").replace("%", "")) || -1; + case "updated": + return row.latest_sample?.ts ?? -1; + default: + return 0; + } +} + +function metricCell(value: string, subtext?: string) { + return ( + + + {value} + + {subtext ? ( + + {subtext} + + ) : null} + + ); +} + +export function MonitoringOverviewTable({ + overview, + embedded = false, +}: { + overview?: MonitoringOverviewResponse; + embedded?: boolean; +}) { + const poller = overview?.poller; + const rows = overview?.machines ?? []; + const [sortKey, setSortKey] = useState("machine"); + const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); + + const sortedRows = useMemo(() => { + const factor = sortDirection === "asc" ? 1 : -1; + return [...rows].sort((a, b) => { + const av = metricSortValue(a, sortKey); + const bv = metricSortValue(b, sortKey); + if (typeof av === "number" && typeof bv === "number") { + return (av - bv) * factor; + } + return String(av).localeCompare(String(bv)) * factor; + }); + }, [rows, sortDirection, sortKey]); + + const setSort = (key: SortKey) => { + if (sortKey === key) { + setSortDirection((current) => (current === "asc" ? "desc" : "asc")); + return; + } + setSortKey(key); + setSortDirection("asc"); + }; + + return ( + + {!embedded ? ( + + + Machine monitoring + + + + + Last success: {formatTime(poller?.last_success_at ?? null)} · last + run: {formatAge(poller?.last_run_at ?? null)} + + + ) : null} + {!embedded && poller?.last_error ? ( + + + Poller error: {poller.last_error} + + + ) : null} + + + + + + setSort("machine")} + > + Machine + + + + setSort("mode")} + > + Mode + + + + setSort("status")} + > + Status + + + + setSort("cpu")} + > + CPU + + + + setSort("iowait")} + > + IO wait + + + + setSort("mem")} + > + RAM + + + + setSort("net_rx")} + > + Net down + + + + setSort("net_tx")} + > + Net up + + + + setSort("disk_read")} + > + Disk read + + + + setSort("disk_write")} + > + Disk write + + + + setSort("disk_used")} + > + Disk used + + + + setSort("updated")} + > + Updated + + + + + + {sortedRows.length === 0 ? ( + + + + No monitoring machines are configured. + + + + ) : ( + sortedRows.map((row) => { + const machine = row.machine; + const status = row.status || row.status_error || "-"; + const note = + row.metrics_error || + row.disk_error || + machine.notes || + row.disk?.mount || + ""; + const cpu = formatSummary( + row.cpu_summary, + (value) => `${value.toFixed(1)}%`, + ); + const iowait = formatSummary( + row.iowait_summary, + (value) => `${value.toFixed(1)}%`, + ); + const mem = formatSummary( + row.mem_summary, + (value) => `${value.toFixed(1)}%`, + ); + const netDown = formatSummary(row.net_rx_summary, formatRate); + const netUp = formatSummary(row.net_tx_summary, formatRate); + const diskRead = formatSummary( + row.disk_read_summary, + formatRate, + ); + const diskWrite = formatSummary( + row.disk_write_summary, + formatRate, + ); + return ( + + + + + + {machine.name} + + {!machine.enabled && ( + + )} + + + {machine.mode === "local" + ? "Local API host" + : `${machine.username || "user"}@${machine.host || "host"}:${machine.port}`} + + + + {machine.mode} + + + + + {metricCell(cpu.value, cpu.subtext)} + + + {metricCell(iowait.value, iowait.subtext)} + + + {metricCell(mem.value, mem.subtext)} + + + {metricCell(netDown.value, netDown.subtext)} + + + {metricCell(netUp.value, netUp.subtext)} + + + {metricCell(diskRead.value, diskRead.subtext)} + + + {metricCell(diskWrite.value, diskWrite.subtext)} + + + {row.disk + ? metricCell( + row.disk.used_pct, + `Used ${formatBytes(row.disk.used)}\nAvail ${formatBytes(row.disk.available)}`, + ) + : "-"} + + + + + {formatTime(row.latest_sample?.ts ?? null)} + + {note && ( + + {note} + + )} + + + + ); + }) + )} + +
+
+
+ ); +} diff --git a/frontend/src/components/SectionCard.tsx b/frontend/src/components/SectionCard.tsx new file mode 100644 index 0000000..db05704 --- /dev/null +++ b/frontend/src/components/SectionCard.tsx @@ -0,0 +1,47 @@ +import type { ReactNode } from "react"; +import { Box, Card, CardContent, Stack, Typography } from "@mui/material"; + +interface SectionCardProps { + title: string; + description?: string; + action?: ReactNode; + children: ReactNode; +} + +export function SectionCard({ + title, + description, + action, + children, +}: SectionCardProps) { + return ( + + + + + + + {title} + + {description ? ( + + {description} + + ) : null} + + {action} + + {children} + + + + ); +} diff --git a/frontend/src/components/SelectionRailCard.tsx b/frontend/src/components/SelectionRailCard.tsx new file mode 100644 index 0000000..05e46ab --- /dev/null +++ b/frontend/src/components/SelectionRailCard.tsx @@ -0,0 +1,71 @@ +import type { ReactNode } from "react"; +import { Box, Card, CardContent, Typography } from "@mui/material"; + +interface SelectionRailCardProps { + title: string; + description?: string; + children: ReactNode; + footer?: ReactNode; + minHeight?: number; + contentSx?: object; + bodySx?: object; +} + +export function SelectionRailCard({ + title, + description, + children, + footer, + minHeight = 420, + contentSx, + bodySx, +}: SelectionRailCardProps) { + return ( + + + + + {title} + + {description ? ( + + {description} + + ) : null} + + {children} + {footer ? ( + + {footer} + + ) : null} + + + ); +} diff --git a/frontend/src/components/TabbedCard.tsx b/frontend/src/components/TabbedCard.tsx new file mode 100644 index 0000000..bdd23ff --- /dev/null +++ b/frontend/src/components/TabbedCard.tsx @@ -0,0 +1,38 @@ +import type { ReactElement, ReactNode } from "react"; +import { Box, Card, CardContent, Tabs } from "@mui/material"; + +interface TabbedCardProps { + value: string; + onChange: (value: string) => void; + tabs: ReactElement[]; + children: ReactNode; + contentSx?: object; + tabsSx?: object; +} + +export function TabbedCard({ + value, + onChange, + tabs, + children, + contentSx, + tabsSx, +}: TabbedCardProps) { + return ( + + + onChange(String(next))} + variant="scrollable" + scrollButtons="auto" + allowScrollButtonsMobile + sx={{ px: 1, borderBottom: 1, borderColor: "divider", ...tabsSx }} + > + {tabs} + + {children} + + + ); +} diff --git a/frontend/src/hooks/useSettings.ts b/frontend/src/hooks/useSettings.ts new file mode 100644 index 0000000..d0a29d6 --- /dev/null +++ b/frontend/src/hooks/useSettings.ts @@ -0,0 +1,160 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + deleteMonitoringMachine, + deleteSSHKey, + fetchMonitoringSettings, + fetchSSHKeys, + fetchSavedTaskRuns, + fetchSavedTasks, + generateSSHKey, + resetLocalDatabase, + saveMonitoringMachine, + saveSSHKey, + saveTask, + deleteTask, + runTask, +} from "../api/client"; +import type { + MonitoringMachineInput, + ResetLocalDatabaseInput, + SavedTaskInput, + SSHKeyInput, +} from "../types"; + +export function useMonitoringSettings() { + return useQuery({ + queryKey: ["settings", "monitoring-machines"], + queryFn: fetchMonitoringSettings, + refetchInterval: 30_000, + }); +} + +export function useSSHKeys() { + return useQuery({ + queryKey: ["settings", "ssh-keys"], + queryFn: fetchSSHKeys, + refetchInterval: 30_000, + }); +} + +export function useGenerateSSHKey() { + return useMutation({ + mutationFn: (payload: { + name: string; + passphrase: string; + notes: string; + bits?: number; + }) => generateSSHKey(payload), + }); +} + +export function useSaveSSHKey() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (key: SSHKeyInput) => saveSSHKey(key), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["settings"] }); + }, + }); +} + +export function useDeleteSSHKey() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (keyId: string) => deleteSSHKey(keyId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["settings"] }); + queryClient.invalidateQueries({ queryKey: ["monitoring"] }); + }, + }); +} + +export function useTasks() { + return useQuery({ + queryKey: ["tasks"], + queryFn: fetchSavedTasks, + refetchInterval: 30_000, + }); +} + +export function useTaskRuns(taskId?: string) { + return useQuery({ + queryKey: ["tasks", taskId ?? "none", "runs"], + queryFn: () => fetchSavedTaskRuns(taskId ?? ""), + enabled: Boolean(taskId), + refetchInterval: 30_000, + }); +} + +export function useSaveTask() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (task: SavedTaskInput) => saveTask(task), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tasks"] }); + }, + }); +} + +export function useDeleteTask() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (taskId: string) => deleteTask(taskId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tasks"] }); + }, + }); +} + +export function useRunTask() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + taskId, + machineId, + }: { + taskId: string; + machineId?: string; + }) => runTask(taskId, machineId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tasks"] }); + }, + }); +} + +export function useSaveMonitoringMachine() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (machine: MonitoringMachineInput) => + saveMonitoringMachine(machine), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["settings"] }); + queryClient.invalidateQueries({ queryKey: ["monitoring"] }); + }, + }); +} + +export function useDeleteMonitoringMachine() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (machineId: string) => deleteMonitoringMachine(machineId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["settings"] }); + queryClient.invalidateQueries({ queryKey: ["monitoring"] }); + }, + }); +} + +export function useResetLocalDatabase() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload: ResetLocalDatabaseInput) => + resetLocalDatabase(payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["settings"] }); + queryClient.invalidateQueries({ queryKey: ["monitoring"] }); + queryClient.invalidateQueries({ queryKey: ["media"] }); + queryClient.invalidateQueries({ queryKey: ["dashboard"] }); + }, + }); +} diff --git a/frontend/src/pages/Actions.tsx b/frontend/src/pages/Actions.tsx new file mode 100644 index 0000000..6d717bf --- /dev/null +++ b/frontend/src/pages/Actions.tsx @@ -0,0 +1,641 @@ +import { useMemo, useState } from "react"; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + Dialog, + DialogContent, + DialogTitle, + Divider, + FormControl, + InputLabel, + MenuItem, + Select, + Stack, + Tab, + Tabs, + TextField, + Typography, +} from "@mui/material"; +import type { MonitoringMachine, SavedTaskInput } from "../types"; +import { + useDeleteTask, + useMonitoringSettings, + useRunTask, + useSaveTask, + useTaskRuns, + useTasks, +} from "../hooks/useSettings"; +import { DialogFooter } from "../components/DialogFooter"; +import { HoverEditButton } from "../components/HoverEditButton"; +import { SelectionRailCard } from "../components/SelectionRailCard"; + +type ActionTab = "new" | string; + +function emptyTask(): SavedTaskInput { + return { + id: null, + name: "", + task_type: "shell", + content: "", + enabled: true, + default_machine_id: "", + notes: "", + }; +} + +function sameTask(a: SavedTaskInput, b: SavedTaskInput) { + return ( + a.id === b.id && + a.name === b.name && + a.task_type === b.task_type && + a.content === b.content && + a.enabled === b.enabled && + a.default_machine_id === b.default_machine_id && + a.notes === b.notes + ); +} + +function TaskEditor({ + task, + machines, + onChange, +}: { + task: SavedTaskInput; + machines: MonitoringMachine[]; + onChange: (task: SavedTaskInput) => void; +}) { + const selectedMachine = machines.find( + (machine) => machine.id === task.default_machine_id, + ); + return ( + + + + {task.id ? "Edit action" : "New action"} + + + + {selectedMachine && ( + + )} + + + + onChange({ ...task, name: e.target.value })} + /> + + + Type + + + + Default machine + + + + onChange({ ...task, notes: e.target.value })} + /> + onChange({ ...task, content: e.target.value })} + helperText={ + task.task_type === "python" + ? "Python is run as `python3 -c`." + : "Shell commands are run through `/bin/sh -c`." + } + /> + + + ); +} + +function TaskDialog({ + open, + task, + baseline, + machines, + onClose, + onChange, + onSave, + onDelete, +}: { + open: boolean; + task: SavedTaskInput; + baseline: SavedTaskInput; + machines: MonitoringMachine[]; + onClose: () => void; + onChange: (task: SavedTaskInput) => void; + onSave: () => void; + onDelete?: () => void; +}) { + const requestClose = () => { + if ( + !sameTask(task, baseline) && + !window.confirm("Discard unsaved changes?") + ) { + return; + } + onClose(); + }; + + return ( + + {task.id ? "Edit action" : "New action"} + + + + + Delete + + ) : undefined + } + /> + + ); +} + +export function Actions() { + const { data: machines = [] } = useMonitoringSettings(); + const { data: tasks = [] } = useTasks(); + const saveTask = useSaveTask(); + const deleteTask = useDeleteTask(); + const runTask = useRunTask(); + const [tab, setTab] = useState("new"); + const [draft, setDraft] = useState(emptyTask()); + const [draftBaseline, setDraftBaseline] = useState( + emptyTask(), + ); + const [runMachineId, setRunMachineId] = useState(""); + const [editOpen, setEditOpen] = useState(false); + + const selectedTask = useMemo( + () => tasks.find((task) => task.id === tab) ?? null, + [tasks, tab], + ); + const selectedRuns = useTaskRuns(selectedTask?.id); + + const createNew = () => { + const initial = emptyTask(); + setDraft(initial); + setDraftBaseline(initial); + setRunMachineId(machines[0]?.id || ""); + setEditOpen(true); + }; + + const saveDraft = async () => { + const saved = await saveTask.mutateAsync(draft); + setTab(saved.id); + setEditOpen(false); + const nextDraft = { + id: saved.id, + name: saved.name, + task_type: saved.task_type, + content: saved.content, + enabled: saved.enabled, + default_machine_id: saved.default_machine_id, + notes: saved.notes, + }; + setDraft(nextDraft); + setDraftBaseline(nextDraft); + }; + + const editingTask = selectedTask; + + return ( + + + + + Actions + + + Save reusable server tasks and switch between them with tabs. + + + + + + {saveTask.error && ( + {String(saveTask.error)} + )} + {deleteTask.error && ( + {String(deleteTask.error)} + )} + {runTask.error && {String(runTask.error)}} + + + + + New action + + } + > + setTab(value)} + orientation="vertical" + variant="scrollable" + sx={{ borderRight: 1, borderColor: "divider" }} + > + {tasks.map((task) => ( + + setTab(task.id)} + onDoubleClick={() => { + const initial = { + id: task.id, + name: task.name, + task_type: task.task_type, + content: task.content, + enabled: task.enabled, + default_machine_id: task.default_machine_id, + notes: task.notes, + }; + setDraft(initial); + setDraftBaseline(initial); + setEditOpen(true); + }} + /> + + { + const initial = { + id: task.id, + name: task.name, + task_type: task.task_type, + content: task.content, + enabled: task.enabled, + default_machine_id: task.default_machine_id, + notes: task.notes, + }; + setDraft(initial); + setDraftBaseline(initial); + setEditOpen(true); + }} + /> + + + ))} + + + + + {editingTask ? ( + + + + + + + {editingTask.name} + + + Open the editor popup to modify this action. + + + + + + + + + + + Run on machine + + + + + + + Recent runs + + {selectedRuns.data?.items?.length ? ( + + {selectedRuns.data.items.map((run) => ( + + + + + + + {run.machine_name} ·{" "} + {new Date( + run.created_at * 1000, + ).toLocaleString()} + + + {run.stdout_tail && ( + + + stdout + + + {run.stdout_tail} + + + )} + {run.stderr_tail && ( + + + stderr + + + {run.stderr_tail} + + + )} + {run.error && ( + {run.error} + )} + + + + ))} + + ) : ( + No runs yet. + )} + + + + ) : ( + + + + + + No action selected + + + Select a saved action from the list on the left to view + its details, run it, or open the editor popup. Use the + button at the bottom to add a new action. + + + + {tasks[0] && ( + + )} + + + + + + + + + + What this panel shows + + + Saved actions stay on the left rail, while details, run + controls, and recent history appear here. + + + + + + )} + + + + setEditOpen(false)} + onChange={setDraft} + onSave={saveDraft} + onDelete={ + draft.id ? () => deleteTask.mutate(String(draft.id)) : undefined + } + /> + + ); +} diff --git a/frontend/src/pages/Applications.tsx b/frontend/src/pages/Applications.tsx new file mode 100644 index 0000000..67e0837 --- /dev/null +++ b/frontend/src/pages/Applications.tsx @@ -0,0 +1,187 @@ +import { useMemo, useState } from "react"; +import { + Alert, + Box, + Card, + CardContent, + Chip, + Grid, + Stack, + Tab, + Typography, +} from "@mui/material"; +import { useSearchParams } from "react-router-dom"; +import { Media } from "./Media"; +import { useCounts, useLibraries } from "../hooks/useDashboard"; +import { useMonitoringSettings } from "../hooks/useSettings"; +import { SectionCard } from "../components/SectionCard"; +import { TabbedCard } from "../components/TabbedCard"; + +function JellyfinLibraryStats() { + const [searchParams] = useSearchParams(); + const { data: machines = [] } = useMonitoringSettings(); + const jellyfinMachines = useMemo( + () => + machines.filter( + (machine) => machine.enabled && machine.services.includes("jellyfin"), + ), + [machines], + ); + const selectedMachineId = + searchParams.get("machine_id") || jellyfinMachines[0]?.id || ""; + const { data: counts } = useCounts(selectedMachineId || undefined); + const { data: libraries } = useLibraries(selectedMachineId || undefined); + + return ( + + } + > + + {counts ? ( + + + + + + Total + + + {( + counts.movies + + counts.series + + counts.episodes + ).toLocaleString()} + + + + + + + + + Movies + + + {counts.movies.toLocaleString()} + + + + + + + + + Series + + + {counts.series.toLocaleString()} + + + + + + + + + Episodes + + + {counts.episodes.toLocaleString()} + + + + + + ) : null} + + {libraries?.length ? ( + + {libraries.map((library) => ( + + + + + + {library.library} + + + Total {library.total.toLocaleString()} · Movies{" "} + {library.movies.toLocaleString()} · Series{" "} + {library.series.toLocaleString()} + + + + + + ))} + + ) : null} + + + ); +} + +export function Applications() { + const [tab, setTab] = useState("jellyfin"); + + return ( + + + + Applications + + + Browse application-specific tools from a compact tabbed workspace. + + + + , + , + ]} + > + {tab === "jellyfin" ? ( + + + + + ) : ( + + + + Nextcloud support will be added in a future update. + + + + )} + + + ); +} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx new file mode 100644 index 0000000..2cafff5 --- /dev/null +++ b/frontend/src/pages/Settings.tsx @@ -0,0 +1,1281 @@ +import { useMemo, useState } from "react"; +import { + Alert, + Box, + Button, + Card, + CardContent, + Checkbox, + Chip, + Dialog, + DialogContent, + DialogTitle, + FormControlLabel, + Grid, + Stack, + Tab, + TextField, + Typography, +} from "@mui/material"; +import type { MonitoringMachineInput, SSHKey, SSHKeyInput } from "../types"; +import { + useDeleteMonitoringMachine, + useDeleteSSHKey, + useGenerateSSHKey, + useMonitoringSettings, + useResetLocalDatabase, + useSSHKeys, + useSaveMonitoringMachine, + useSaveSSHKey, +} from "../hooks/useSettings"; +import { DialogFooter } from "../components/DialogFooter"; +import { HoverEditButton } from "../components/HoverEditButton"; +import { SectionCard } from "../components/SectionCard"; +import { SelectionRailCard } from "../components/SelectionRailCard"; +import { TabbedCard } from "../components/TabbedCard"; + +const SERVICE_OPTIONS = [ + { value: "monitoring", label: "Monitoring" }, + { value: "files", label: "Files" }, + { value: "jellyfin", label: "Jellyfin" }, + { value: "jellyseerr", label: "Jellyseerr" }, + { value: "nextcloud", label: "Nextcloud" }, +]; + +type SettingsTab = "machines" | "ssh-keys" | "danger"; + +function emptyMachine( + mode: MonitoringMachineInput["mode"] = "local", +): MonitoringMachineInput { + return { + id: null, + name: mode === "local" ? "This machine" : "", + mode, + enabled: true, + services: mode === "local" ? ["monitoring", "files", "jellyfin"] : [], + host: "", + port: 22, + username: "", + key_directory: "", + key_name: "", + ssh_key_id: "", + ssh_private_key: "", + ssh_private_key_passphrase: "", + password: "", + media_root: "", + path_prefix: "", + jellyfin_url: "", + jellyfin_user_id: "", + jellyfin_api_key: "", + jellyseerr_url: "", + jellyseerr_api_key: "", + notes: "", + }; +} + +function MachineEditor({ + title, + hint, + machine, + sshKeys, +}: { + title: string; + hint?: string; + machine: MonitoringMachineInput; + sshKeys: SSHKey[]; +}) { + const [draft, setDraft] = useState(machine); + const isLocal = draft.mode === "local"; + const selectedSSHKey = sshKeys.find((key) => key.id === draft.ssh_key_id); + const enabledServices = draft.services.length; + const hasJellyfin = draft.services.includes("jellyfin"); + const hasJellyseerr = draft.services.includes("jellyseerr"); + + return ( + + + + + + + {title} + + {hint && ( + + {hint} + + )} + + + + + + + + + + + + setDraft((current) => ({ ...current, name: e.target.value })) + } + /> + + + + + + + + + + {SERVICE_OPTIONS.map((option) => { + const checked = draft.services.includes(option.value); + return ( + + setDraft((current) => ({ + ...current, + services: checked + ? current.services.filter( + (service) => service !== option.value, + ) + : [...current.services, option.value], + })) + } + /> + ); + })} + + + {!isLocal && ( + <> + + + setDraft((current) => ({ + ...current, + host: e.target.value, + })) + } + /> + + + + setDraft((current) => ({ + ...current, + port: Number(e.target.value || 22), + })) + } + /> + + + + setDraft((current) => ({ + ...current, + username: e.target.value, + })) + } + /> + + + + setDraft((current) => ({ + ...current, + ssh_key_id: e.target.value, + })) + } + /> + + + + setDraft((current) => ({ + ...current, + key_directory: e.target.value, + })) + } + /> + + + + setDraft((current) => ({ + ...current, + key_name: e.target.value, + })) + } + /> + + + + setDraft((current) => ({ + ...current, + password: e.target.value, + })) + } + /> + + + )} + + + setDraft((current) => ({ + ...current, + media_root: e.target.value, + })) + } + /> + + + + setDraft((current) => ({ + ...current, + path_prefix: e.target.value, + })) + } + /> + + {hasJellyfin && ( + <> + + + setDraft((current) => ({ + ...current, + jellyfin_url: e.target.value, + })) + } + /> + + + + setDraft((current) => ({ + ...current, + jellyfin_user_id: e.target.value, + })) + } + /> + + + + setDraft((current) => ({ + ...current, + jellyfin_api_key: e.target.value, + })) + } + /> + + + )} + {hasJellyseerr && ( + <> + + + setDraft((current) => ({ + ...current, + jellyseerr_url: e.target.value, + })) + } + /> + + + + setDraft((current) => ({ + ...current, + jellyseerr_api_key: e.target.value, + })) + } + /> + + + )} + {isLocal && ( + + + + )} + + + setDraft((current) => ({ ...current, notes: e.target.value })) + } + /> + {" "} + + + {selectedSSHKey && ( + + Selected key: {selectedSSHKey.name} + {selectedSSHKey.fingerprint + ? ` · ${selectedSSHKey.fingerprint}` + : ""} + + )} + {!isLocal && !hasJellyfin && ( + + SSH machines usually need monitoring or files enabled. + + )} + {hasJellyseerr && !draft.jellyseerr_url && ( + + Jellyseerr is enabled, but no URL is configured yet. + + )} + + + + ); +} + +function SSHKeyManager({ sshKeys }: { sshKeys: SSHKey[] }) { + const saveKey = useSaveSSHKey(); + const generateKey = useGenerateSSHKey(); + const deleteKey = useDeleteSSHKey(); + const [draft, setDraft] = useState({ + id: null, + name: "", + private_key: "", + passphrase: "", + notes: "", + }); + const editing = Boolean(draft.id); + + const clear = () => + setDraft({ + id: null, + name: "", + private_key: "", + passphrase: "", + notes: "", + }); + + return ( + + + + + + + SSH keys + + + Import or generate reusable keys for SSH machines. + + + + + + + + + setDraft((current) => ({ ...current, name: e.target.value })) + } + /> + + + + setDraft((current) => ({ + ...current, + passphrase: e.target.value, + })) + } + helperText="Optional" + /> + + + + setDraft((current) => ({ ...current, notes: e.target.value })) + } + /> + + + + setDraft((current) => ({ + ...current, + private_key: e.target.value, + })) + } + helperText={ + editing + ? "Leave blank to keep the existing private key." + : "Paste the full key text here." + } + /> + + + + + + + + + + {saveKey.error && ( + {String(saveKey.error)} + )} + + {sshKeys.length > 0 ? ( + + {sshKeys.map((key) => ( + + + + + + + {key.name} + + + {key.passphrase_set && ( + + )} + + + {key.notes && ( + + {key.notes} + + )} + + + Fingerprint + + + {key.fingerprint || "Unavailable"} + + + + + Public key + + + {key.public_key || "Unavailable"} + + + + + + + + + + + ))} + + ) : ( + No SSH keys saved yet. + )} + + + + ); +} + +function ResetLocalDatabaseCard() { + const resetDatabase = useResetLocalDatabase(); + const [open, setOpen] = useState(false); + const [phrase, setPhrase] = useState(""); + const [ackSettings, setAckSettings] = useState(false); + const [ackIndex, setAckIndex] = useState(false); + const [ackIrreversible, setAckIrreversible] = useState(false); + const canSubmit = + phrase.trim().toLowerCase() === "reset local database" && + ackSettings && + ackIndex && + ackIrreversible; + + const close = () => { + setOpen(false); + setPhrase(""); + setAckSettings(false); + setAckIndex(false); + setAckIrreversible(false); + }; + + return ( + <> + + + + + + Danger zone + + + + + Reset the local SQLite settings/media index databases after + acknowledging the data loss. + + + {resetDatabase.error && ( + {String(resetDatabase.error)} + )} + + + + + + Reset local database + + + + This deletes the local settings and media index SQLite files. It + does not affect remote servers. + + setAckSettings(e.target.checked)} + /> + } + label="I understand settings will be lost." + /> + setAckIndex(e.target.checked)} + /> + } + label="I understand the media index will be rebuilt." + /> + setAckIrreversible(e.target.checked)} + /> + } + label="I understand this cannot be undone." + /> + setPhrase(e.target.value)} + /> + + + { + await resetDatabase.mutateAsync({ + confirm_phrase: phrase, + acknowledge_settings_loss: ackSettings, + acknowledge_media_index_loss: ackIndex, + acknowledge_irreversible: ackIrreversible, + }); + close(); + }} + confirmLabel="Reset database" + confirmBusyLabel="Resetting..." + confirmColor="error" + confirmDisabled={!canSubmit || resetDatabase.isPending} + /> + + + ); +} + +export function Settings() { + const { data: machines, error } = useMonitoringSettings(); + const { data: sshKeys = [] } = useSSHKeys(); + const saveMachine = useSaveMonitoringMachine(); + const deleteMachine = useDeleteMonitoringMachine(); + const [tab, setTab] = useState("machines"); + const [machineDialogOpen, setMachineDialogOpen] = useState(false); + const [machineDraft, setMachineDraft] = useState( + emptyMachine(), + ); + const [selectedMachineId, setSelectedMachineId] = useState(""); + + const orderedMachines = useMemo(() => machines ?? [], [machines]); + const selectedMachine = useMemo( + () => + orderedMachines.find((machine) => machine.id === selectedMachineId) ?? + orderedMachines[0] ?? + null, + [orderedMachines, selectedMachineId], + ); + const localMachines = orderedMachines.filter( + (machine) => machine.mode === "local", + ); + const sshMachines = orderedMachines.filter( + (machine) => machine.mode === "ssh", + ); + + const beginLocal = () => { + setMachineDraft(emptyMachine("local")); + setMachineDialogOpen(true); + }; + const beginRemote = () => { + setMachineDraft(emptyMachine("ssh")); + setMachineDialogOpen(true); + }; + const openEditMachine = (machine: MonitoringMachineInput) => { + setMachineDraft(machine); + setMachineDialogOpen(true); + }; + const closeMachineDialog = () => { + setMachineDialogOpen(false); + }; + const saveMachineDraft = async (draft: MonitoringMachineInput) => { + await saveMachine.mutateAsync(draft); + setMachineDialogOpen(false); + setMachineDraft(emptyMachine(draft.mode)); + }; + + return ( + + + + Settings + + + Structure machines, reusable SSH keys, and safety controls from a + tabbed admin workspace. + + + + {error && {String(error)}} + {saveMachine.error && ( + {String(saveMachine.error)} + )} + {deleteMachine.error && ( + {String(deleteMachine.error)} + )} + + setTab(value as SettingsTab)} + tabs={[ + , + , + , + ]} + contentSx={{ p: 1.5 }} + > + {tab === "machines" && ( + + + + + + + + Quick actions + + + Create a local profile or remote SSH target. + + + + + + + + + + + + + + + + + {orderedMachines.length > 0 ? ( + + + + {orderedMachines.map((machine) => { + const active = machine.id === selectedMachine?.id; + return ( + setSelectedMachineId(machine.id)} + sx={{ + display: "grid", + gridTemplateColumns: "minmax(0, 1fr) auto", + gap: 1, + px: 1.25, + py: 1.1, + borderTop: 1, + borderColor: "divider", + cursor: "pointer", + width: "100%", + bgcolor: active + ? "action.selected" + : "background.paper", + "&:hover .rail-edit": { opacity: 1 }, + }} + > + + + {machine.name} + + + {machine.mode} ·{" "} + {machine.enabled ? "Enabled" : "Disabled"} + + + { + openEditMachine({ + id: machine.id, + name: machine.name, + mode: machine.mode, + enabled: machine.enabled, + services: machine.services, + host: machine.host, + port: machine.port, + username: machine.username, + key_directory: machine.key_directory, + key_name: machine.key_name, + ssh_key_id: machine.ssh_key_id, + ssh_private_key: "", + ssh_private_key_passphrase: "", + password: "", + media_root: machine.media_root, + path_prefix: machine.path_prefix, + jellyfin_url: machine.jellyfin_url, + jellyfin_user_id: machine.jellyfin_user_id, + jellyfin_api_key: "", + jellyseerr_url: machine.jellyseerr_url, + jellyseerr_api_key: "", + notes: machine.notes, + }); + }} + /> + + ); + })} + + + + + ) : undefined + } + > + {selectedMachine ? ( + + + + + {selectedMachine.ssh_key_id && ( + + )} + + + {selectedMachine.notes || "No notes."} + + + {selectedMachine.mode === "ssh" ? ( + <> + + + + + + ) : ( + + )} + + + + {selectedMachine.id !== "local" && ( + + )} + + + ) : null} + + + ) : null} + + )} + + {tab === "ssh-keys" && } + {tab === "danger" && } + + + + + {machineDraft.id ? "Edit machine" : "Create machine"} + + + + + { + void saveMachineDraft(machineDraft); + }} + confirmLabel={machineDraft.id ? "Save machine" : "Create machine"} + confirmDisabled={ + !machineDraft.name || + (machineDraft.mode === "ssh" && !machineDraft.host.trim()) + } + secondaryAction={ + machineDraft.id && machineDraft.id !== "local" ? ( + + ) : undefined + } + /> + + + ); +}