Add missing frontend and backend files
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user