Add missing frontend and backend files

This commit is contained in:
2026-05-06 23:46:08 +02:00
parent b789034bbe
commit 016e3255f5
20 changed files with 5226 additions and 0 deletions
@@ -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))
@@ -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,
}
@@ -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
@@ -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
@@ -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
@@ -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,
)
@@ -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
@@ -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