refactor: unify SSH machines as services

This commit is contained in:
Developer
2026-07-14 20:58:46 +00:00
parent fe90feb1b7
commit 37533dd219
42 changed files with 3103 additions and 4960 deletions
@@ -1,12 +1,12 @@
"""Dependency injection for FastAPI.
Provides access to service-specific Jellyfin/Jellyseerr clients and
machine-specific SSH clients via FastAPI's request context.
remote-machine SSH clients via FastAPI's request context.
- Jellyfin/Jellyseerr are selected with a ``jellyfin_service_id`` query
parameter (resolved against the service registry); the backend falls back to
the first enabled ``jellyfin``/``jellyseerr`` service instance.
- SSH/Files transport is selected with ``machine_id`` as before.
- SSH/Files transport is selected with an enabled ``remote_machine`` ``service_id``.
"""
from __future__ import annotations
@@ -18,9 +18,7 @@ from typing import Any
from fastapi import HTTPException, Request
from media_library_viewer_api.clients.jellyfin import JellyfinClient
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.services.mail_queue import MailQueue
from media_library_viewer_api.services.mail_queue import get_mail_queue as _get_mail_queue
from media_library_viewer_api.services.settings_store import SettingsStore
@@ -29,11 +27,11 @@ from media_library_viewer_api.services.settings_store import get_settings_store
logger = logging.getLogger(__name__)
def _request_machine_id(request: Request | None) -> str | None:
def _request_remote_machine_service_id(request: Request | None) -> str | None:
if request is None:
return None
machine_id = request.query_params.get("machine_id")
return machine_id or None
service_id = request.query_params.get("service_id")
return service_id or None
def _request_jellyfin_service_id(request: Request | None) -> str | None:
@@ -80,87 +78,10 @@ def _jellyfin_client_for(cache_key: tuple[str, str, str]) -> JellyfinClient:
return JellyfinClient(url, api_key)
@lru_cache(maxsize=32)
def _ssh_client_for(
cache_key: tuple[str, str, str, int, str, str | None, str | None, str | None, str | None],
) -> RemoteSSHClient:
machine_id, host, username, port, key_filename, password, private_key, private_key_passphrase, known_hosts_path = (
cache_key
)
logger.info(
"Creating SSH client machine_id=%s host=%s user=%s port=%s key=%s password=%s private_key=%s passphrase=%s",
machine_id or "<default>",
host or "<unset>",
username or "<unset>",
port,
key_filename or "<unset>",
"set" if password else "missing",
"set" if private_key else "missing",
"set" if private_key_passphrase else "missing",
)
client = RemoteSSHClient(
host=host,
username=username,
port=port,
key_filename=key_filename or None,
private_key=private_key or None,
private_key_passphrase=private_key_passphrase or None,
password=password or None,
known_hosts_path=known_hosts_path or None,
)
try:
client.connect()
except RuntimeError as exc:
message = str(exc)
lowered = message.lower()
logger.exception("Failed to establish SSH connection to %s", host or "<unset>")
if "banner" in lowered:
raise HTTPException(
status_code=502,
detail=(
f"SSH banner not received from {host}:{port}. "
"Confirm the host, port, and firewall; the backend could not complete the SSH handshake."
),
) from exc
if "authentication failed" in lowered or "no authentication methods available" in lowered:
raise HTTPException(
status_code=401,
detail=(
f"SSH authentication failed for {host}:{port}. "
"Check the selected key, passphrase, username, or password."
),
) from exc
raise HTTPException(status_code=502, detail=message) from exc
except Exception:
logger.exception("Failed to establish SSH connection to %s", host or "<unset>")
raise
return client
def _resolve_machine(service: str, request: Request | None = None) -> dict[str, Any] | None:
"""Resolve an SSH/Files machine for the given transport service.
Jellyfin/Jellyseerr are resolved against the service registry, not here.
"""
def get_jellyfin_client(request: Request) -> JellyfinClient:
"""Return a Jellyfin client for the selected enabled service instance."""
store = get_settings_store()
machine_id = _request_machine_id(request)
if machine_id:
machine = store.get_machine(machine_id)
if machine and (service in machine.get("services", []) or service == "ssh"):
return machine
return machine
if service == "ssh":
machines = store.list_machines_for_service("files") or store.list_machines_for_service("monitoring")
else:
machines = store.list_machines_for_service(service)
return machines[0] if machines else None
def get_jellyfin_client(request: Request = None) -> JellyfinClient:
"""Return a Jellyfin client for the selected Jellyfin service instance."""
store = get_settings_store()
service_id = _request_jellyfin_service_id(request)
service = _service_record(store, "jellyfin", service_id)
service = _service_record(store, "jellyfin", _request_jellyfin_service_id(request))
if service is None:
raise HTTPException(
status_code=503,
@@ -173,83 +94,25 @@ def get_jellyfin_client(request: Request = None) -> JellyfinClient:
status_code=503,
detail="Jellyfin service is missing base_url or api_key. Edit it on the Services page.",
)
cache_key = (service["id"], base_url, api_key)
return _jellyfin_client_for(cache_key)
return _jellyfin_client_for((service["id"], base_url, api_key))
def _ssh_client_from_machine_config(machine: dict[str, Any], store: SettingsStore | None = None) -> RemoteSSHClient:
"""Build a RemoteSSHClient from a machine config dict."""
store = store or get_settings_store()
known_hosts_path = get_settings().ssh_known_hosts_file
key_data = None
key_passphrase = None
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:
key_data = ssh_key.get("private_key") or None
key_passphrase = ssh_key.get("passphrase") or None
if not key_data and machine.get("ssh_private_key"):
key_data = machine.get("ssh_private_key") or None
key_passphrase = machine.get("ssh_private_key_passphrase") or None
cache_key = (
machine["id"],
machine["host"],
machine["username"],
int(machine.get("port") or 22),
f"{machine.get('key_directory')}/{machine.get('key_name')}"
if machine.get("key_directory") and machine.get("key_name")
else "",
machine.get("password") or None,
key_data,
key_passphrase,
str(known_hosts_path),
)
return _ssh_client_for(cache_key)
def get_ssh_client(request: Request) -> RemoteSSHClient:
"""Return SSH transport for the requested enabled remote-machine service."""
from media_library_viewer_api.services.task_runner import build_ssh_client
from media_library_viewer_api.widgets.sources import build_service_record
def get_ssh_client(request: Request = None):
"""Return a command client for the selected machine or legacy env fallback."""
store = get_settings_store()
machine_id = _request_machine_id(request)
machine = store.get_machine_config(machine_id) if machine_id else None
if machine is None:
machine_ref = _resolve_machine("ssh", request)
machine = store.get_machine_config(machine_ref["id"]) if machine_ref else None
if machine and str(machine.get("mode") or "local").strip().lower() == "local":
logger.info("Creating LocalCommandClient machine_id=%s", machine["id"])
return LocalCommandClient()
if machine and machine.get("host") and machine.get("username"):
return _ssh_client_from_machine_config(machine, store)
settings = get_settings()
logger.info(
"Creating SSH client from legacy env host=%s user=%s port=%s key_dir=%s key_name=%s password=%s",
settings.ssh_host or "<unset>",
settings.ssh_username or "<unset>",
settings.ssh_port,
settings.ssh_key_directory or "<unset>",
settings.ssh_key_name or "<unset>",
"set" if settings.ssh_password else "missing",
)
if not settings.ssh_key_path:
raise HTTPException(
status_code=503,
detail="No SSH machine is configured and SSH key settings must be configured",
)
return _ssh_client_for(
(
"legacy",
settings.ssh_host,
settings.ssh_username,
settings.ssh_port,
settings.ssh_key_path,
settings.ssh_password or None,
None,
None,
str(settings.ssh_known_hosts_file),
)
)
service_id = _request_remote_machine_service_id(request)
if not service_id:
raise HTTPException(status_code=400, detail="service_id is required for remote file and job operations")
row = store.get_service(service_id)
if not row or row.get("service_type") != "remote_machine" or not row.get("enabled", True):
raise HTTPException(status_code=404, detail="Enabled remote machine service not found")
try:
return build_ssh_client(store, build_service_record(store, row))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
def get_mail_queue() -> MailQueue:
@@ -262,7 +125,7 @@ def get_settings_store() -> SettingsStore:
return _get_settings_store()
def get_user_id(request: Request = None) -> str:
def get_user_id(request: Request) -> str:
"""Return the Jellyfin user Id, resolving a configured username if needed.
The service ``user_id`` config field accepts either the internal Jellyfin Id
@@ -108,7 +108,7 @@ class TestResult:
#: A test routine receives (config, secrets, store). The store is needed for
#: ssh_tasks (SSH-key resolution). Other types ignore it.
#: remote_machine (SSH-key resolution). Other types ignore it.
TestCallable = Callable[[dict[str, Any], dict[str, str], "SettingsStore"], TestResult]
@@ -14,7 +14,7 @@ from media_library_viewer_api.integrations.jellyfin import DEFINITION as JELLYFI
from media_library_viewer_api.integrations.nextcloud import DEFINITION as NEXTCLOUD
from media_library_viewer_api.integrations.prometheus import DEFINITION as PROMETHEUS
from media_library_viewer_api.integrations.qbittorrent import DEFINITION as QBITTORRENT
from media_library_viewer_api.integrations.ssh_tasks import DEFINITION as SSH_TASKS
from media_library_viewer_api.integrations.remote_machine import DEFINITION as REMOTE_MACHINE
SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = {
PROMETHEUS.service_type: PROMETHEUS,
@@ -22,7 +22,7 @@ SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = {
JELLYFIN.service_type: JELLYFIN,
NEXTCLOUD.service_type: NEXTCLOUD,
QBITTORRENT.service_type: QBITTORRENT,
SSH_TASKS.service_type: SSH_TASKS,
REMOTE_MACHINE.service_type: REMOTE_MACHINE,
BACKUPS.service_type: BACKUPS,
AUTHENTIK.service_type: AUTHENTIK,
}
@@ -1,6 +1,6 @@
"""SSH task runner service definition.
"""Remote machine service definition.
An ``ssh_tasks`` instance is an SSH endpoint that can run reusable saved tasks.
An ``remote_machine`` instance is an SSH endpoint that can run reusable saved tasks.
Tasks themselves stay in the global saved-task registry; the instance only owns
transport (host/port/user/key). Every run is recorded in ``service_task_runs``
and shown as history on the instance's service page.
@@ -42,7 +42,7 @@ def test_connection(
try:
service = ServiceRecord(
id="",
service_type="ssh_tasks",
service_type="remote_machine",
name="test",
config=config,
secrets=secrets,
@@ -77,8 +77,8 @@ def test_connection(
return translate_connection_error(exc, context=f"SSH {host}:{port}")
class SshTasksConfig(ServiceConfigBase):
"""Non-secret SSH task runner config.
class RemoteMachineConfig(ServiceConfigBase):
"""Non-secret Remote machine config.
The SSH key itself lives in the saved SSH-key registry and is referenced by
``ssh_key_id``. An optional ``passphrase`` is stored as a secret.
@@ -91,7 +91,7 @@ class SshTasksConfig(ServiceConfigBase):
timeout_seconds: int = 30
class SshTaskOutputWidgetConfig(WidgetConfigBase):
class RemoteMachineTaskOutputWidgetConfig(WidgetConfigBase):
"""Output of a saved task run on this instance."""
task_id: str
@@ -100,19 +100,20 @@ class SshTaskOutputWidgetConfig(WidgetConfigBase):
DEFINITION = ServiceDefinition(
service_type="ssh_tasks",
name="SSH task runner",
description="Run reusable saved tasks over SSH and keep run history.",
config_model=SshTasksConfig,
service_type="remote_machine",
name="Remote machine",
description="SSH transport for files and reusable actions.",
config_model=RemoteMachineConfig,
secret_fields=[
SecretField(key="passphrase", label="Key passphrase", helper="Optional"),
SecretField(key="password", label="SSH password", helper="Optional"),
],
widget_kinds=[
widget_kind(
kind="task_output",
name="Task output",
description="Output of a saved task run.",
model_cls=SshTaskOutputWidgetConfig,
model_cls=RemoteMachineTaskOutputWidgetConfig,
default_config={"task_id": ""},
refresh_interval_ms=0,
),
@@ -52,42 +52,6 @@ JOB_TEMPLATES: dict[str, JobTemplate] = {
description="Lists empty directories under the selected path. Does not delete anything.",
command_template="find {path} -type d -empty -print",
),
"install_node_exporter": JobTemplate(
name="Install Node Exporter",
description="Downloads and installs prometheus-node-exporter via package manager (apt/dnf/yum/zypper).",
command_template=(
"set -e; "
"if command -v apt-get >/dev/null 2>&1; then "
"sudo apt-get update && sudo apt-get install -y prometheus-node-exporter; "
"elif command -v dnf >/dev/null 2>&1; then "
"sudo dnf install -y prometheus-node-exporter; "
"elif command -v yum >/dev/null 2>&1; then "
"sudo yum install -y prometheus-node-exporter; "
"elif command -v zypper >/dev/null 2>&1; then "
"sudo zypper install -y prometheus-node-exporter; "
"else echo 'No supported package manager found' >&2; exit 1; "
"fi; "
"sudo systemctl enable --now prometheus-node-exporter; "
"echo installed at {path}"
),
),
"restart_node_exporter": JobTemplate(
name="Restart Node Exporter",
description="Restarts the prometheus-node-exporter systemd service.",
command_template="sudo systemctl restart prometheus-node-exporter; echo restarted at {path}",
),
"node_exporter_status": JobTemplate(
name="Node Exporter status",
description="Checks whether prometheus-node-exporter is installed, enabled, and running.",
command_template=(
"systemctl status prometheus-node-exporter --no-pager || true; "
"echo '---'; "
"command -v node_exporter >/dev/null 2>&1 "
"&& node_exporter --version 2>&1 | head -1 "
"|| echo 'node_exporter binary not found'; "
"echo checked {path}"
),
),
}
@@ -18,7 +18,6 @@ from media_library_viewer_api.clients.http_timeout import http_timeout
from media_library_viewer_api.dependencies import get_settings_store
from media_library_viewer_api.services.service_resolution import resolve_service_record
from media_library_viewer_api.services.settings_store import SettingsStore
from media_library_viewer_api.services.targets import build_node_exporter_targets
from media_library_viewer_api.widgets.sources import ServiceRecord
logger = logging.getLogger(__name__)
@@ -59,21 +58,6 @@ def _summary_from_alerts(alerts: list[dict[str, Any]]) -> dict[str, Any]:
router = APIRouter(prefix="/api/monitoring", tags=["monitoring"])
@router.get("/machines")
def get_machines(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]:
"""Return enabled monitoring machines for the UI."""
return [m for m in store.list_machines() if m.get("enabled")]
@router.get("/prometheus-targets")
def get_prometheus_targets(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]:
"""Return Prometheus scrape targets for remote Node Exporters.
External Prometheus instances consume this list via ``http_sd_configs``.
"""
targets = build_node_exporter_targets(store)
logger.info("Prometheus targets requested count=%s", len(targets))
return targets
@router.get("/alerts")
@@ -10,11 +10,8 @@ import paramiko
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
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.db_maintenance import remove_sqlite_database
from media_library_viewer_api.services.known_hosts import has_known_host
from media_library_viewer_api.services.media_index import MediaIndex
from media_library_viewer_api.services.settings_store import SettingsStore
@@ -23,188 +20,6 @@ logger = logging.getLogger(__name__)
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 = ""
notes: str = ""
@router.get("/machines")
def get_machines(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]:
return store.list_machines()
def _resolve_ssh_client(
machine: MonitoringMachineInput,
store: SettingsStore,
) -> tuple[RemoteSSHClient, str, int]:
host = machine.host.strip()
username = machine.username.strip()
port = int(machine.port or 22)
if not host or not username:
raise HTTPException(status_code=400, detail="SSH machine is missing host or username")
private_key = machine.ssh_private_key
passphrase = machine.ssh_private_key_passphrase
if machine.ssh_key_id:
ssh_key = store.get_ssh_key(machine.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.key_directory and machine.key_name:
key_filename = f"{machine.key_directory}/{machine.key_name}"
settings = get_settings()
client = RemoteSSHClient(
host=host,
username=username,
port=port,
key_filename=key_filename or None,
private_key=private_key or None,
private_key_passphrase=passphrase or None,
password=machine.password or None,
known_hosts_path=str(settings.ssh_known_hosts_file),
)
return client, host, port
def _raise_ssh_validation_error(host: str, port: int, exc: Exception) -> None:
message = str(exc)
lowered = message.lower()
if "protocol banner" in lowered:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=(f"SSH banner not received from {host}:{port}; the backend could not complete the SSH handshake."),
) from exc
if "no authentication methods available" in lowered or "authentication failed" in lowered:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=(
f"SSH authentication failed for {host}:{port}. "
"Check the selected SSH key, passphrase, username, or password."
),
) from exc
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"SSH validation failed for {host}:{port}: {message}",
) from exc
def _validate_saved_machine_ssh(machine: MonitoringMachineInput, store: SettingsStore) -> None:
if str(machine.mode or "").strip().lower() != "ssh":
return
client, host, port = _resolve_ssh_client(machine, store)
try:
client.connect()
except Exception as exc:
_raise_ssh_validation_error(host, port, exc)
finally:
client.close()
@router.post("/machines/test-ssh")
def test_machine_ssh(
machine: MonitoringMachineInput,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
if str(machine.mode or "").strip().lower() != "ssh":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="SSH validation only applies to SSH machines"
)
client, host, port = _resolve_ssh_client(machine, store)
settings = get_settings()
known_hosts_updated = not has_known_host(host, port, settings.ssh_known_hosts_file)
try:
client.connect()
except Exception as exc:
message = str(exc)
lowered = message.lower()
if "protocol banner" in lowered:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=(
f"SSH banner not received from {host}:{port}; the backend recorded the host key, "
"but SSH auth could not be validated. Confirm the SSH service is running."
),
) from exc
if "no authentication methods available" in lowered or "authentication failed" in lowered:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=(
f"SSH banner received from {host}:{port}, but authentication failed. "
"Check the selected SSH key, passphrase, username, or password."
),
) from exc
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"SSH validation failed for {host}:{port}: {message}",
) from exc
finally:
client.close()
return {
"status": "ok",
"message": (
f"SSH connection succeeded for {host}:{port}; host key "
f"{'was recorded' if known_hosts_updated else 'was already trusted'} and authentication worked."
),
"host": host,
"port": port,
"known_hosts_updated": known_hosts_updated,
}
@router.post("/machines", status_code=status.HTTP_201_CREATED)
def post_machine(
machine: MonitoringMachineInput,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
saved = store.upsert_machine(machine.model_dump(exclude_none=True), machine.id)
saved_machine = MonitoringMachineInput.model_validate(saved)
_validate_saved_machine_ssh(saved_machine, store)
return saved
@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")
saved = store.upsert_machine(machine.model_dump(exclude_none=True), machine_id)
saved_machine = MonitoringMachineInput.model_validate(saved)
_validate_saved_machine_ssh(saved_machine, store)
return saved
@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="")
@@ -24,7 +24,7 @@ class TaskInput(BaseModel):
task_type: str = Field(default="shell", description="shell or python")
content: str = Field(default="")
enabled: bool = True
default_service_id: str = ""
service_id: str = ""
notes: str = ""
@@ -38,18 +38,16 @@ def _service_label(service: dict[str, Any] | None) -> str:
return str(service.get("name") or service.get("id") or "")
def _resolve_service_for_task(
store: SettingsStore,
task: dict[str, Any],
service_id: str | None,
) -> dict[str, Any] | None:
if service_id:
return store.get_service(service_id)
default_service_id = str(task.get("default_service_id") or "").strip()
if default_service_id:
return store.get_service(default_service_id)
services = [svc for svc in store.list_services("ssh_tasks") if svc.get("enabled")]
return services[0] if services else None
def _owned_remote_machine(store: SettingsStore, service_id: str) -> dict[str, Any] | None:
service = store.get_service(service_id)
if service and service.get("service_type") == "remote_machine" and service.get("enabled", True):
return service
return None
def _require_task_owner(task: dict[str, Any], service_id: str) -> None:
if str(task.get("service_id") or "") != service_id:
raise HTTPException(status_code=404, detail="Task not found for this remote machine service")
def _service_row_to_record(service_row: dict[str, Any]) -> ServiceRecord:
@@ -60,12 +58,23 @@ def _service_row_to_record(service_row: dict[str, Any]) -> ServiceRecord:
@router.get("")
def list_tasks(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]:
return store.list_tasks()
def list_tasks(
service_id: str = Query(..., min_length=1),
store: SettingsStore = Depends(get_settings_store),
) -> list[dict[str, Any]]:
if not _owned_remote_machine(store, service_id):
raise HTTPException(status_code=404, detail="Enabled remote machine service not found")
return [task for task in store.list_tasks() if task.get("service_id") == service_id]
def _validate_task_owner(task: TaskInput, store: SettingsStore) -> None:
if not task.service_id or not _owned_remote_machine(store, task.service_id):
raise HTTPException(status_code=400, detail="Task owner must be an enabled remote machine service")
@router.post("", status_code=status.HTTP_201_CREATED)
def create_task(task: TaskInput, store: SettingsStore = Depends(get_settings_store)) -> dict[str, Any]:
_validate_task_owner(task, store)
return store.upsert_task(task.model_dump(exclude_none=True), task.id)
@@ -73,13 +82,20 @@ def create_task(task: TaskInput, store: SettingsStore = Depends(get_settings_sto
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")
_validate_task_owner(task, store)
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):
def delete_task(
task_id: str,
service_id: str = Query(..., min_length=1),
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, str]:
task = store.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
_require_task_owner(task, service_id)
store.delete_task(task_id)
return {"status": "deleted"}
@@ -87,19 +103,22 @@ def delete_task(task_id: str, store: SettingsStore = Depends(get_settings_store)
@router.get("/{task_id}/runs")
def list_task_runs(
task_id: str,
service_id: str = Query(..., min_length=1),
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):
task = store.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
runs = store.list_service_task_runs(task_id=task_id, limit=limit)
_require_task_owner(task, service_id)
runs = store.list_service_task_runs(service_id=service_id, task_id=task_id, limit=limit)
return {"items": runs, "total": len(runs)}
@router.post("/run")
def run_task(
request: RunTaskRequest,
service_id: str | None = Query(default=None),
service_id: str = Query(..., min_length=1),
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
task = store.get_task(request.task_id)
@@ -108,11 +127,10 @@ def run_task(
if not task.get("enabled", True):
raise HTTPException(status_code=400, detail="Task is disabled")
service_row = _resolve_service_for_task(store, task, service_id)
_require_task_owner(task, service_id)
service_row = _owned_remote_machine(store, service_id)
if not service_row:
raise HTTPException(status_code=400, detail="No SSH task service is available for this action")
if not service_row.get("enabled", True):
raise HTTPException(status_code=400, detail="Selected SSH task service is disabled")
raise HTTPException(status_code=404, detail="Enabled remote machine service not found")
service = _service_row_to_record(service_row)
result = run_saved_task(store, task, service)
@@ -1,9 +1,4 @@
"""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.
"""
"""Persistent application settings stored in a small SQLite database."""
from __future__ import annotations
@@ -23,31 +18,6 @@ from media_library_viewer_api.models.widgets import _validate_config_keys
logger = logging.getLogger(__name__)
DEFAULT_SETTINGS_PATH = Path(".cache/media_library_viewer/settings.sqlite")
LOCAL_MACHINE_ID = "local"
DEFAULT_SERVICES = ["monitoring", "files"]
def _default_local_machine() -> dict[str, Any]:
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": "",
"ssh_private_key_passphrase": "",
"password": "",
"node_exporter_enabled": False,
"node_exporter_port": 9100,
"node_exporter_scrape_host": "",
"notes": "",
}
class SettingsStore:
@@ -66,23 +36,6 @@ class SettingsStore:
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)")
# The legacy SSH-scraping monitor (MonitoringPoller) was decommissioned;
# metrics now live in Prometheus/node_exporter. Drop the orphan
# table on startup so existing databases get a clean slate.
conn.execute("DROP TABLE IF EXISTS monitoring_machine_actions")
conn.execute(
"""
@@ -112,7 +65,7 @@ class SettingsStore:
task_type TEXT NOT NULL,
content TEXT NOT NULL,
enabled INTEGER NOT NULL,
default_service_id TEXT NOT NULL,
service_id TEXT NOT NULL,
notes TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
@@ -120,11 +73,15 @@ class SettingsStore:
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_saved_tasks_name ON saved_tasks(name)")
# saved_tasks.default_machine_id → default_service_id (saved tasks now
# target ssh_tasks service instances). Migrate existing columns.
# Migrate legacy task ownership column names in place.
saved_tasks_cols = {row[1] for row in conn.execute("PRAGMA table_info(saved_tasks)").fetchall()}
if "default_service_id" not in saved_tasks_cols and "default_machine_id" in saved_tasks_cols:
conn.execute("ALTER TABLE saved_tasks RENAME COLUMN default_machine_id TO default_service_id")
if "service_id" not in saved_tasks_cols:
legacy_column = next(
(column for column in ("default_service_id", "default_machine_id") if column in saved_tasks_cols),
None,
)
if legacy_column:
conn.execute(f"ALTER TABLE saved_tasks RENAME COLUMN {legacy_column} TO service_id")
# Run history for saved tasks now lives in service_task_runs; the
# legacy machine-based table is dropped.
conn.execute("DROP TABLE IF EXISTS saved_task_runs")
@@ -276,191 +233,139 @@ class SettingsStore:
)
"""
)
self._migrate_remote_machine_services(conn)
@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 _migrate_remote_machine_services(self, conn: sqlite3.Connection) -> None:
"""Migrate legacy SSH endpoints into encrypted ``remote_machine`` services.
def _row_to_machine(self, row: sqlite3.Row) -> dict[str, Any]:
data = json.loads(row["config_json"])
default_services = DEFAULT_SERVICES if row["id"] == LOCAL_MACHINE_ID else []
services = self._normalize_services(data.get("services"), default_services)
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")),
"node_exporter_enabled": bool(data.get("node_exporter_enabled", False)),
"node_exporter_port": int(data.get("node_exporter_port", 9100) or 9100),
"node_exporter_scrape_host": data.get("node_exporter_scrape_host", ""),
"notes": data.get("notes", ""),
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
Local placeholders are deliberately skipped. Invalid legacy rows abort
the transaction, retaining the source table instead of silently losing
credential material.
"""
tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
conn.execute("UPDATE services SET service_type = 'remote_machine' WHERE service_type = 'ssh_tasks'")
if "monitoring_machines" not in tables:
return
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", []))
from media_library_viewer_api.services.secrets import encrypt_value
def _current_str(field: str, default: str = "") -> str:
return str(
payload.get(field) if payload.get(field) is not None else (current or {}).get(field, default) or default
).strip()
host = _current_str("host")
port = int(payload.get("port") or (current or {}).get("port", 22) or 22)
username = _current_str("username")
key_directory = _current_str("key_directory")
key_name = _current_str("key_name")
ssh_key_id = _current_str("ssh_key_id")
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 "")
node_exporter_enabled = bool(
payload.get("node_exporter_enabled")
if payload.get("node_exporter_enabled") is not None
else (current or {}).get("node_exporter_enabled", False)
)
node_exporter_port_raw = payload.get("node_exporter_port")
if node_exporter_port_raw is None:
node_exporter_port_raw = (current or {}).get("node_exporter_port", 9100)
node_exporter_port = int(node_exporter_port_raw or 9100)
node_exporter_scrape_host = _current_str("node_exporter_scrape_host")
notes = _current_str("notes")
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,
"node_exporter_enabled": node_exporter_enabled,
"node_exporter_port": node_exporter_port,
"node_exporter_scrape_host": node_exporter_scrape_host,
"notes": notes,
}
def _seed_local_machine(self) -> None:
"""Seed the default local machine if none exists."""
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": "",
"node_exporter_enabled": machine["node_exporter_enabled"],
"node_exporter_port": machine["node_exporter_port"],
"node_exporter_scrape_host": machine["node_exporter_scrape_host"],
"notes": machine["notes"],
}
with self.connect() as conn:
rows = conn.execute("SELECT * FROM monitoring_machines ORDER BY created_at, id").fetchall()
for row in rows:
try:
data = json.loads(row["config_json"] or "{}")
except (TypeError, json.JSONDecodeError) as exc:
raise RuntimeError(f"Legacy machine {row['id']!r} has invalid config JSON") from exc
if not isinstance(data, dict):
raise RuntimeError(f"Legacy machine {row['id']!r} config must be an object")
if str(row["mode"] or "").lower() != "ssh":
continue
old_id = str(row["id"])
target_id = self._remote_machine_target_id(conn, old_id)
ssh_key_id = self._migrate_inline_ssh_key(conn, row, data, old_id)
config = self._legacy_remote_machine_config(data, ssh_key_id, old_id)
secrets = self._legacy_remote_machine_secrets(data, encrypt_value)
conn.execute(
"""
INSERT INTO monitoring_machines (id, name, mode, enabled, config_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
INSERT INTO services (
id, service_type, name, config_json, secrets_json, enabled, created_at, updated_at
) VALUES (?, 'remote_machine', ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO NOTHING
""",
(
machine["id"],
machine["name"],
machine["mode"],
1,
target_id,
row["name"],
json.dumps(config),
now,
now,
json.dumps(secrets),
row["enabled"],
row["created_at"],
row["updated_at"],
),
)
if target_id != old_id:
conn.execute("UPDATE saved_tasks SET service_id = ? WHERE service_id = ?", (target_id, old_id))
conn.execute("UPDATE service_task_runs SET service_id = ? WHERE service_id = ?", (target_id, old_id))
conn.execute("UPDATE dashboard_widgets SET service_id = ? WHERE service_id = ?", (target_id, old_id))
conn.execute("DROP TABLE monitoring_machines")
def _seed_dashboard_widgets(self) -> None:
"""Default widget seeding was removed.
@staticmethod
def _remote_machine_target_id(conn: sqlite3.Connection, old_id: str) -> str:
existing = conn.execute("SELECT service_type FROM services WHERE id = ?", (old_id,)).fetchone()
if not existing or existing[0] == "remote_machine":
return old_id
base = f"remote-machine-{old_id}"
target_id, suffix = base, 2
while conn.execute("SELECT 1 FROM services WHERE id = ?", (target_id,)).fetchone():
target_id = f"{base}-{suffix}"
suffix += 1
return target_id
Widgets are now service-bound (or built-in). A fresh install starts with
no widgets; the user configures services and adds widgets from the UI.
Kept as a no-op so :meth:`ensure_defaults` callers are unchanged.
"""
return None
def _migrate_inline_ssh_key(
self, conn: sqlite3.Connection, row: sqlite3.Row, data: dict[str, Any], old_id: str
) -> str:
ssh_key_id = str(data.get("ssh_key_id") or "").strip()
inline_key = str(data.get("ssh_private_key") or "")
if not inline_key or ssh_key_id:
return ssh_key_id
base = f"legacy-key-{old_id}"
ssh_key_id, suffix = base, 2
while conn.execute("SELECT 1 FROM ssh_keys WHERE id = ?", (ssh_key_id,)).fetchone():
ssh_key_id = f"{base}-{suffix}"
suffix += 1
summary = self._private_key_summary(inline_key)
conn.execute(
"""
INSERT INTO ssh_keys (
id, name, private_key, passphrase, public_key, fingerprint, notes, created_at, updated_at
) VALUES (?, ?, ?, '', ?, ?, ?, ?, ?)
""",
(
ssh_key_id,
f"Migrated key for {row['name']}",
inline_key,
summary["public_key"],
summary["fingerprint"],
"Migrated from legacy remote machine",
row["created_at"],
row["updated_at"],
),
)
return ssh_key_id
@staticmethod
def _legacy_remote_machine_config(data: dict[str, Any], ssh_key_id: str, machine_id: str) -> dict[str, Any]:
try:
port = int(data.get("port") or 22)
timeout = int(data.get("timeout_seconds") or 30)
except (TypeError, ValueError) as exc:
raise RuntimeError(f"Legacy machine {machine_id!r} has invalid SSH port or timeout") from exc
if not 1 <= port <= 65535 or timeout <= 0:
raise RuntimeError(f"Legacy machine {machine_id!r} has invalid SSH port or timeout")
return {
"host": str(data.get("host") or ""),
"port": port,
"username": str(data.get("username") or ""),
"ssh_key_id": ssh_key_id,
"timeout_seconds": timeout,
}
@staticmethod
def _legacy_remote_machine_secrets(data: dict[str, Any], encrypt_value: Any) -> dict[str, str]:
secrets: dict[str, str] = {}
for legacy, secret in (("ssh_private_key_passphrase", "passphrase"), ("password", "password")):
value = str(data.get(legacy) or "")
if value:
secrets[secret] = encrypt_value(value)
return secrets
def ensure_defaults(self) -> None:
self.init_schema()
with self.connect() as conn:
row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone()
if not row or int(row[0]) == 0:
self._seed_local_machine()
self._migrate_jellyseerr_into_jellyfin()
self._migrate_jellyseerr_api_key_to_secret()
def _migrate_jellyseerr_api_key_to_secret(self) -> None:
"""Move Jellyfin's plaintext ``jellyseerr_api_key`` from config into secrets.
The key was originally a plaintext config field; it is now a secret.
Idempotent: once no Jellyfin config carries the key this is a no-op. Uses
a direct UPDATE so existing (encrypted) secrets are preserved untouched
rather than re-encrypted.
"""
"""Move Jellyfin's plaintext ``jellyseerr_api_key`` from config into secrets."""
from media_library_viewer_api.services.secrets import encrypt_value
self.init_schema()
moved = 0
for row in self.list_services("jellyfin"):
config = dict(row.get("config") or {})
@@ -476,27 +381,15 @@ class SettingsStore:
"UPDATE services SET config_json = ?, secrets_json = ?, updated_at = ? WHERE id = ?",
(json.dumps(config), json.dumps(secrets_blob), int(time.time()), row["id"]),
)
conn.commit()
moved += 1
logger.info(
"migrated jellyseerr_api_key config->secret for jellyfin service %r",
row["name"],
)
logger.info("migrated jellyseerr_api_key config->secret for jellyfin service %r", row["name"])
if moved:
logger.info("migrated jellyseerr_api_key to secret for %s jellyfin service(s)", moved)
def _migrate_jellyseerr_into_jellyfin(self) -> None:
"""Absorb standalone ``jellyseerr`` services into their paired Jellyfin.
Idempotent: once no ``jellyseerr`` rows remain the method is a no-op.
Pairing policy: exactly-one Jellyfin merges; multiple picks the first
Jellyfin whose ``jellyseerr_url`` is still empty; no Jellyfin or all
paired -> drop with a logged warning.
"""
"""Absorb standalone ``jellyseerr`` services into their paired Jellyfin."""
from media_library_viewer_api.services.secrets import decrypt_value
self.init_schema()
jellyseerr_rows: list[sqlite3.Row] = []
with self.connect() as conn:
jellyseerr_rows = conn.execute(
"SELECT * FROM services WHERE service_type = 'jellyseerr' ORDER BY name ASC"
@@ -510,28 +403,23 @@ class SettingsStore:
js_secrets = json.loads(js_row["secrets_json"] or "{}")
js_url = str(js_config.get("base_url", "")).strip()
js_api_key = str(js_secrets.get("api_key", "")).strip()
# Decrypt the api_key (secrets are stored encrypted; config is plaintext).
if js_api_key:
try:
js_api_key = decrypt_value(js_api_key)
except Exception:
logger.warning("could not decrypt jellyseerr api_key for %r", js_row["name"])
js_api_key = ""
js_name = js_row["name"]
target = None
if len(jellyfin_rows) == 1:
target = jellyfin_rows[0]
elif len(jellyfin_rows) > 1:
for jf in jellyfin_rows:
if not str(jf["config"].get("jellyseerr_url", "")).strip():
target = jf
break
target = next(
(row for row in jellyfin_rows if not str(row["config"].get("jellyseerr_url", "")).strip()),
None,
)
if target:
# list_services returns the stored (encrypted) secrets blob, so
# decrypt the existing Jellyfin api_key before handing it back to
# upsert_service (which re-encrypts) — otherwise it double-encrypts.
target_api_key = str(target["secrets"].get("api_key") or "")
if target_api_key:
try:
@@ -549,142 +437,14 @@ class SettingsStore:
"config": merged_config,
"enabled": target["enabled"],
},
secret_values={
"api_key": target_api_key,
"jellyseerr_api_key": js_api_key,
},
secret_values={"api_key": target_api_key, "jellyseerr_api_key": js_api_key},
)
logger.info("migrated jellyseerr service %r into jellyfin service %r", js_name, target["name"])
logger.info("migrated jellyseerr service %r into jellyfin service %r", js_row["name"], target["name"])
else:
logger.warning(
"dropped unpaired jellyseerr service %r; reconfigure manually on the Jellyfin instance",
js_name,
)
logger.warning("dropped unpaired jellyseerr service %r; reconfigure manually", js_row["name"])
with self.connect() as conn:
conn.execute("DELETE FROM services WHERE id = ?", (js_row["id"],))
conn.commit()
def list_machines(self) -> list[dict[str, Any]]:
self.init_schema()
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.init_schema()
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.init_schema()
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", ""),
"node_exporter_enabled": bool(data.get("node_exporter_enabled", False)),
"node_exporter_port": int(data.get("node_exporter_port", 9100) or 9100),
"node_exporter_scrape_host": data.get("node_exporter_scrape_host", ""),
"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"],
"node_exporter_enabled": machine["node_exporter_enabled"],
"node_exporter_port": machine["node_exporter_port"],
"node_exporter_scrape_host": machine["node_exporter_scrape_host"],
"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,))
@staticmethod
def _private_key_summary(private_key: str) -> dict[str, str]:
@@ -755,10 +515,9 @@ class SettingsStore:
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()
for service in self.list_services("remote_machine"):
ssh_key_id = str((service.get("config") or {}).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:
@@ -833,7 +592,7 @@ class SettingsStore:
"task_type": row["task_type"],
"content": row["content"],
"enabled": bool(row["enabled"]),
"default_service_id": row["default_service_id"],
"service_id": row["service_id"],
"notes": row["notes"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
@@ -850,10 +609,10 @@ class SettingsStore:
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_service_id = str(
payload.get("default_service_id")
if payload.get("default_service_id") is not None
else (current or {}).get("default_service_id", "") or ""
service_id = str(
payload.get("service_id")
if payload.get("service_id") is not None
else (current or {}).get("service_id", "") or ""
).strip()
notes = str(
payload.get("notes") if payload.get("notes") is not None else (current or {}).get("notes", "") or ""
@@ -864,7 +623,7 @@ class SettingsStore:
"task_type": task_type,
"content": content,
"enabled": enabled,
"default_service_id": default_service_id,
"service_id": service_id,
"notes": notes,
}
@@ -892,7 +651,7 @@ class SettingsStore:
conn.execute(
"""
INSERT INTO saved_tasks (
id, name, task_type, content, enabled, default_service_id,
id, name, task_type, content, enabled, service_id,
notes, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -901,7 +660,7 @@ class SettingsStore:
task_type = excluded.task_type,
content = excluded.content,
enabled = excluded.enabled,
default_service_id = excluded.default_service_id,
service_id = excluded.service_id,
notes = excluded.notes,
updated_at = excluded.updated_at
""",
@@ -911,7 +670,7 @@ class SettingsStore:
task["task_type"],
task["content"],
1 if task["enabled"] else 0,
task["default_service_id"],
task["service_id"],
task["notes"],
created_at,
now,
@@ -1,58 +0,0 @@
"""Prometheus Node Exporter target discovery.
The backend owns the list of remote Node Exporter targets so that operators can
enable scraping per machine from the Manage UI. The list is exposed over HTTP at
``GET /api/monitoring/prometheus-targets`` and consumed by an external Prometheus
via ``http_sd_configs`` (no shared volume required).
"""
from __future__ import annotations
import logging
from typing import Any
from media_library_viewer_api.services.settings_store import SettingsStore
logger = logging.getLogger(__name__)
DEFAULT_NODE_EXPORTER_PORT = 9100
def _scrape_address(machine: dict[str, Any]) -> str | None:
"""Return host:port for the Node Exporter on a machine, or None if disabled."""
if not machine.get("node_exporter_enabled"):
return None
scrape_host = str(machine.get("node_exporter_scrape_host") or "").strip()
host = scrape_host or str(machine.get("host") or "").strip()
if not host or host == "localhost":
return None
port = int(machine.get("node_exporter_port") or DEFAULT_NODE_EXPORTER_PORT)
return f"{host}:{port}"
def build_node_exporter_targets(store: SettingsStore) -> list[dict[str, Any]]:
"""Build an http-SD target list for all enabled SSH machines.
Local machines are excluded because the Docker host is scraped directly.
"""
targets: list[dict[str, Any]] = []
for machine in store.list_machines():
if not machine.get("enabled"):
continue
if str(machine.get("mode") or "local").strip().lower() != "ssh":
continue
address = _scrape_address(machine)
if not address:
continue
targets.append(
{
"targets": [address],
"labels": {
"job": "node-exporter-remote",
"machine_id": str(machine.get("id") or ""),
"machine_name": str(machine.get("name") or ""),
"instance": address,
},
}
)
return targets
@@ -1,7 +1,7 @@
"""Shared runner for saved tasks over SSH task services.
"""Shared runner for saved tasks over Remote machine services.
Both the Actions page (``routers/tasks.py``) and the SSH task widget
(``widgets/sources.py``) run saved tasks against ``ssh_tasks`` service instances.
(``widgets/sources.py``) run saved tasks against ``remote_machine`` service instances.
This module is the single execution path: build the client from the service
record, render the command, run it with the service timeout, append a
``service_task_runs`` row, and return the result.
@@ -40,12 +40,12 @@ class TaskRunResult:
def build_ssh_client(store: SettingsStore, service: "ServiceRecord") -> RemoteSSHClient:
"""Build an SSH client from an ssh_tasks service instance + referenced key."""
"""Build an SSH client from an remote_machine service instance + referenced key."""
config = service.config
host = str(config.get("host") or "").strip()
username = str(config.get("username") or "").strip()
if not host or not username:
raise ValueError("SSH task service is missing host or username")
raise ValueError("Remote machine service is missing host or username")
settings = get_settings()
private_key = ""
@@ -65,6 +65,7 @@ def build_ssh_client(store: SettingsStore, service: "ServiceRecord") -> RemoteSS
port=int(config.get("port") or 22),
private_key=private_key or None,
private_key_passphrase=key_passphrase or None,
password=str(service.secrets.get("password") or "") or None,
known_hosts_path=str(settings.ssh_known_hosts_file),
timeout=int(config.get("timeout_seconds") or 30),
)
@@ -88,7 +89,7 @@ def run_saved_task(
*,
timeout: int | None = None,
) -> TaskRunResult:
"""Run a saved task on an ssh_tasks service instance and log the run.
"""Run a saved task on an remote_machine service instance and log the run.
The ``timeout`` defaults to the service's ``timeout_seconds`` config. The run
is recorded in ``service_task_runs`` regardless of outcome (success, failure,
@@ -531,7 +531,7 @@ SERVICE_ADAPTERS: dict[str, WidgetSource] = {
"qbittorrent": QbittorrentWidgetSource(),
"alertmanager": AlertmanagerWidgetSource(),
"jellyfin": JellyfinWidgetSource(),
"ssh_tasks": SshTaskWidgetSource(),
"remote_machine": SshTaskWidgetSource(),
}
BUILTIN_ADAPTERS: dict[str, WidgetSource] = {