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 (
+
+ );
+}
+
+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)}
+ )}
+
+
+
+
+
+ >
+ );
+}
+
+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" && }
+
+
+
+
+ );
+}