refactor: unify SSH machines as services
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
+12
-11
@@ -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] = {
|
||||
|
||||
@@ -257,8 +257,7 @@ class TestSettingsReset:
|
||||
assert payload["status"] == "reset"
|
||||
assert not media_db.exists()
|
||||
assert not media_wal.exists()
|
||||
assert store.get_machine("local") is None
|
||||
assert len(store.list_machines()) == 0
|
||||
assert store.list_services("remote_machine") == []
|
||||
|
||||
|
||||
# --- Files ---
|
||||
@@ -469,35 +468,6 @@ class TestJobs:
|
||||
# --- Monitoring ---
|
||||
|
||||
|
||||
class TestMonitoring:
|
||||
def test_prometheus_targets_empty(self, test_client):
|
||||
response = test_client.get("/api/monitoring/prometheus-targets")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
def test_prometheus_targets_returns_enabled_ssh_node_exporter(self, test_client):
|
||||
store = app.dependency_overrides[get_settings_store]()
|
||||
store.upsert_machine(
|
||||
{
|
||||
"name": "remote1",
|
||||
"mode": "ssh",
|
||||
"enabled": True,
|
||||
"services": ["monitoring"],
|
||||
"host": "10.0.0.5",
|
||||
"username": "u",
|
||||
"node_exporter_enabled": True,
|
||||
"node_exporter_port": 9200,
|
||||
"node_exporter_scrape_host": "1.2.3.4",
|
||||
}
|
||||
)
|
||||
response = test_client.get("/api/monitoring/prometheus-targets")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["targets"] == ["1.2.3.4:9200"]
|
||||
assert data[0]["labels"]["job"] == "node-exporter-remote"
|
||||
|
||||
|
||||
class TestResolveServiceRecord:
|
||||
"""Unit tests for resolve_service_record (service_id + first-enabled paths)."""
|
||||
|
||||
@@ -569,46 +539,6 @@ class TestResolveServiceRecord:
|
||||
assert resolve_service_record(store, "alertmanager", None) is None
|
||||
|
||||
|
||||
class TestSettingsMachines:
|
||||
def test_machine_appears_in_prometheus_targets(self, test_client):
|
||||
store = app.dependency_overrides[get_settings_store]()
|
||||
store.upsert_machine(
|
||||
{
|
||||
"name": "remote1",
|
||||
"mode": "ssh",
|
||||
"enabled": True,
|
||||
"services": ["monitoring"],
|
||||
"host": "10.0.0.5",
|
||||
"username": "u",
|
||||
"node_exporter_enabled": True,
|
||||
"node_exporter_port": 9200,
|
||||
"node_exporter_scrape_host": "1.2.3.4",
|
||||
}
|
||||
)
|
||||
targets = test_client.get("/api/monitoring/prometheus-targets").json()
|
||||
assert len(targets) == 1
|
||||
assert targets[0]["targets"] == ["1.2.3.4:9200"]
|
||||
|
||||
def test_delete_machine_removed_from_prometheus_targets(self, test_client):
|
||||
store = app.dependency_overrides[get_settings_store]()
|
||||
machine = store.upsert_machine(
|
||||
{
|
||||
"name": "remote1",
|
||||
"mode": "ssh",
|
||||
"enabled": True,
|
||||
"services": ["monitoring"],
|
||||
"host": "10.0.0.5",
|
||||
"username": "u",
|
||||
"node_exporter_enabled": True,
|
||||
"node_exporter_port": 9200,
|
||||
"node_exporter_scrape_host": "1.2.3.4",
|
||||
}
|
||||
)
|
||||
response = test_client.delete(f"/api/settings/machines/{machine['id']}")
|
||||
assert response.status_code == 200
|
||||
assert test_client.get("/api/monitoring/prometheus-targets").json() == []
|
||||
|
||||
|
||||
def _am_service(name="Alertmanager", **config):
|
||||
cfg = {"base_url": "http://alertmanager:9093", "timeout_seconds": 5}
|
||||
cfg.update(config)
|
||||
|
||||
@@ -14,7 +14,7 @@ from media_library_viewer_api.integrations.jellyfin import test_connection as jf
|
||||
from media_library_viewer_api.integrations.nextcloud import test_connection as nc_test
|
||||
from media_library_viewer_api.integrations.prometheus import test_connection as prom_test
|
||||
from media_library_viewer_api.integrations.qbittorrent import test_connection as qbit_test
|
||||
from media_library_viewer_api.integrations.ssh_tasks import test_connection as ssh_test
|
||||
from media_library_viewer_api.integrations.remote_machine import test_connection as ssh_test
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# translate_connection_error (CT-119)
|
||||
@@ -26,32 +26,32 @@ class TestTranslateConnectionError:
|
||||
resp = SimpleNamespace(status_code=401)
|
||||
exc = requests.HTTPError(response=resp)
|
||||
result = translate_connection_error(exc)
|
||||
assert result.ok is False
|
||||
assert not result.ok
|
||||
assert "Authentication failed" in result.detail
|
||||
|
||||
def test_http_403_maps_to_auth_message(self) -> None:
|
||||
resp = SimpleNamespace(status_code=403)
|
||||
exc = requests.HTTPError(response=resp)
|
||||
result = translate_connection_error(exc)
|
||||
assert result.ok is False
|
||||
assert not result.ok
|
||||
assert "Authentication failed" in result.detail
|
||||
|
||||
def test_connection_error_dns_maps_to_host_not_found(self) -> None:
|
||||
exc = requests.ConnectionError("getaddrinfo failed")
|
||||
result = translate_connection_error(exc)
|
||||
assert result.ok is False
|
||||
assert not result.ok
|
||||
assert "Host not found" in result.detail
|
||||
|
||||
def test_timeout_maps_to_timed_out(self) -> None:
|
||||
exc = requests.Timeout("timed out")
|
||||
result = translate_connection_error(exc)
|
||||
assert result.ok is False
|
||||
assert not result.ok
|
||||
assert "timed out" in result.detail.lower()
|
||||
|
||||
def test_generic_fallback_includes_context(self) -> None:
|
||||
exc = ValueError("something weird happened")
|
||||
result = translate_connection_error(exc, context="qBittorrent")
|
||||
assert result.ok is False
|
||||
assert not result.ok
|
||||
assert "qBittorrent" in result.detail
|
||||
assert "something weird happened" in result.detail
|
||||
|
||||
@@ -71,7 +71,7 @@ class TestQbittorrentTestConnection:
|
||||
{"username": "u", "password": "p"},
|
||||
MagicMock(),
|
||||
)
|
||||
assert result.ok is True
|
||||
assert result.ok
|
||||
assert result.evidence == "v4.6.0"
|
||||
|
||||
def test_login_failed_translates_to_auth_message(self) -> None:
|
||||
@@ -82,7 +82,7 @@ class TestQbittorrentTestConnection:
|
||||
)
|
||||
with patch("media_library_viewer_api.integrations.qbittorrent.QbittorrentClient", return_value=mock_client):
|
||||
result = qbit_test({"base_url": "http://qb:8080"}, {"username": "u", "password": "p"}, MagicMock())
|
||||
assert result.ok is False
|
||||
assert not result.ok
|
||||
assert "Authentication failed" in result.detail
|
||||
|
||||
def test_gateway_error_does_not_masquerade_as_auth_failure(self) -> None:
|
||||
@@ -94,7 +94,7 @@ class TestQbittorrentTestConnection:
|
||||
)
|
||||
with patch("media_library_viewer_api.integrations.qbittorrent.QbittorrentClient", return_value=mock_client):
|
||||
result = qbit_test({"base_url": "http://qb:8080"}, {"username": "u", "password": "p"}, MagicMock())
|
||||
assert result.ok is False
|
||||
assert not result.ok
|
||||
assert "Authentication failed" not in result.detail
|
||||
assert "504" in result.detail
|
||||
|
||||
@@ -103,7 +103,7 @@ class TestQbittorrentTestConnection:
|
||||
mock_client.maindata.side_effect = requests.ConnectionError("Connection refused")
|
||||
with patch("media_library_viewer_api.integrations.qbittorrent.QbittorrentClient", return_value=mock_client):
|
||||
result = qbit_test({"base_url": "http://qb:8080"}, {"username": "u", "password": "p"}, MagicMock())
|
||||
assert result.ok is False
|
||||
assert not result.ok
|
||||
assert "Connection refused" in result.detail
|
||||
|
||||
|
||||
@@ -121,17 +121,17 @@ class TestPrometheusTestConnection:
|
||||
{"grafana_api_key": "tok"},
|
||||
MagicMock(),
|
||||
)
|
||||
assert result.ok is True
|
||||
assert result.ok
|
||||
assert "Gateway" in (result.evidence or "")
|
||||
|
||||
def test_missing_url_returns_error_without_network(self) -> None:
|
||||
result = prom_test({}, {"grafana_api_key": "tok"}, MagicMock())
|
||||
assert result.ok is False
|
||||
assert not result.ok
|
||||
assert "URL" in result.detail
|
||||
|
||||
def test_missing_api_key_returns_error_without_network(self) -> None:
|
||||
result = prom_test({"grafana_url": "http://grafana:3000"}, {}, MagicMock())
|
||||
assert result.ok is False
|
||||
assert not result.ok
|
||||
assert "API key" in result.detail
|
||||
|
||||
def test_http_401_translates_to_auth(self) -> None:
|
||||
@@ -143,7 +143,7 @@ class TestPrometheusTestConnection:
|
||||
{"grafana_api_key": "wrong"},
|
||||
MagicMock(),
|
||||
)
|
||||
assert result.ok is False
|
||||
assert not result.ok
|
||||
assert "Authentication failed" in result.detail
|
||||
|
||||
|
||||
@@ -160,7 +160,7 @@ class TestAlertmanagerTestConnection:
|
||||
)
|
||||
with patch("media_library_viewer_api.integrations.alertmanager.requests.get", return_value=payload):
|
||||
result = am_test({"base_url": "http://am:9093"}, {}, MagicMock())
|
||||
assert result.ok is True
|
||||
assert result.ok
|
||||
assert result.evidence == "0.27.0"
|
||||
|
||||
def test_connection_refused_translates(self) -> None:
|
||||
@@ -169,7 +169,7 @@ class TestAlertmanagerTestConnection:
|
||||
side_effect=requests.ConnectionError("refused"),
|
||||
):
|
||||
result = am_test({"base_url": "http://am:9093"}, {}, MagicMock())
|
||||
assert result.ok is False
|
||||
assert not result.ok
|
||||
assert "Connection refused" in result.detail
|
||||
|
||||
|
||||
@@ -184,7 +184,7 @@ class TestJellyfinTestConnection:
|
||||
mock_client.users.return_value = [{"Name": "a"}, {"Name": "b"}]
|
||||
with patch("media_library_viewer_api.integrations.jellyfin.JellyfinClient", return_value=mock_client):
|
||||
result = jf_test({"base_url": "http://jf:8096"}, {"api_key": "k"}, MagicMock())
|
||||
assert result.ok is True
|
||||
assert result.ok
|
||||
assert "2 users" == result.evidence
|
||||
|
||||
def test_http_401_translates_to_auth(self) -> None:
|
||||
@@ -192,7 +192,7 @@ class TestJellyfinTestConnection:
|
||||
mock_client.users.side_effect = requests.HTTPError(response=SimpleNamespace(status_code=401))
|
||||
with patch("media_library_viewer_api.integrations.jellyfin.JellyfinClient", return_value=mock_client):
|
||||
result = jf_test({"base_url": "http://jf:8096"}, {"api_key": "wrong"}, MagicMock())
|
||||
assert result.ok is False
|
||||
assert not result.ok
|
||||
assert "Authentication failed" in result.detail
|
||||
|
||||
|
||||
@@ -207,7 +207,7 @@ class TestAuthentikTestConnection:
|
||||
mock_client.users.return_value = {"total": 5, "items": []}
|
||||
with patch("media_library_viewer_api.integrations.authentik.AuthentikClient", return_value=mock_client):
|
||||
result = ak_test({"base_url": "http://ak:9000"}, {"api_token": "tok"}, MagicMock())
|
||||
assert result.ok is True
|
||||
assert result.ok
|
||||
assert "5 users" == result.evidence
|
||||
|
||||
def test_connection_error_translates(self) -> None:
|
||||
@@ -215,7 +215,7 @@ class TestAuthentikTestConnection:
|
||||
mock_client.users.side_effect = requests.ConnectionError("refused")
|
||||
with patch("media_library_viewer_api.integrations.authentik.AuthentikClient", return_value=mock_client):
|
||||
result = ak_test({"base_url": "http://ak:9000"}, {"api_token": "tok"}, MagicMock())
|
||||
assert result.ok is False
|
||||
assert not result.ok
|
||||
assert "Connection refused" in result.detail
|
||||
|
||||
|
||||
@@ -229,7 +229,7 @@ class TestSshTasksTestConnection:
|
||||
mock_client = MagicMock()
|
||||
with patch("media_library_viewer_api.services.task_runner.build_ssh_client", return_value=mock_client):
|
||||
result = ssh_test({"host": "srv", "port": 22, "username": "u"}, {"passphrase": ""}, MagicMock())
|
||||
assert result.ok is True
|
||||
assert result.ok
|
||||
assert "Connected to srv:22" == result.evidence
|
||||
|
||||
def test_auth_failed_translates_to_ssh_auth_message(self) -> None:
|
||||
@@ -237,7 +237,7 @@ class TestSshTasksTestConnection:
|
||||
mock_client.connect.side_effect = Exception("SSH authentication failed")
|
||||
with patch("media_library_viewer_api.services.task_runner.build_ssh_client", return_value=mock_client):
|
||||
result = ssh_test({"host": "srv", "port": 22, "username": "u"}, {}, MagicMock())
|
||||
assert result.ok is False
|
||||
assert not result.ok
|
||||
assert "SSH authentication failed" in result.detail
|
||||
|
||||
def test_protocol_banner_translates(self) -> None:
|
||||
@@ -245,12 +245,12 @@ class TestSshTasksTestConnection:
|
||||
mock_client.connect.side_effect = Exception("protocol banner error")
|
||||
with patch("media_library_viewer_api.services.task_runner.build_ssh_client", return_value=mock_client):
|
||||
result = ssh_test({"host": "srv", "port": 22, "username": "u"}, {}, MagicMock())
|
||||
assert result.ok is False
|
||||
assert not result.ok
|
||||
assert "SSH banner" in result.detail
|
||||
|
||||
def test_missing_host_returns_value_error(self) -> None:
|
||||
result = ssh_test({"host": "", "username": "u"}, {}, MagicMock())
|
||||
assert result.ok is False
|
||||
assert not result.ok
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -263,7 +263,7 @@ class TestNextcloudTestConnection:
|
||||
payload = SimpleNamespace(raise_for_status=lambda: None, json=lambda: {"version": "29.0.0"})
|
||||
with patch("media_library_viewer_api.integrations.nextcloud.requests.get", return_value=payload):
|
||||
result = nc_test({"base_url": "http://nc:80"}, {}, MagicMock())
|
||||
assert result.ok is True
|
||||
assert result.ok
|
||||
assert result.evidence == "29.0.0"
|
||||
|
||||
def test_connection_error_translates(self) -> None:
|
||||
@@ -272,5 +272,5 @@ class TestNextcloudTestConnection:
|
||||
side_effect=requests.ConnectionError("refused"),
|
||||
):
|
||||
result = nc_test({"base_url": "http://nc:80"}, {}, MagicMock())
|
||||
assert result.ok is False
|
||||
assert not result.ok
|
||||
assert "Connection refused" in result.detail
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import sqlite3
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from media_library_viewer_api.services.secrets import decrypt_secrets, reset_encryption_key_cache
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
|
||||
def test_migrates_legacy_ssh_machine_and_ssh_task_service(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MANAGE_ENCRYPTION_KEY", Fernet.generate_key().decode())
|
||||
reset_encryption_key_cache()
|
||||
db_path = tmp_path / "settings.sqlite"
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.executescript("""
|
||||
CREATE TABLE monitoring_machines (
|
||||
id TEXT PRIMARY KEY, name TEXT, mode TEXT, enabled INTEGER,
|
||||
config_json TEXT, created_at INTEGER, updated_at INTEGER
|
||||
);
|
||||
CREATE TABLE services (
|
||||
id TEXT PRIMARY KEY, service_type TEXT, name TEXT, config_json TEXT,
|
||||
secrets_json TEXT, enabled INTEGER, created_at INTEGER, updated_at INTEGER
|
||||
);
|
||||
""")
|
||||
conn.execute(
|
||||
"INSERT INTO monitoring_machines VALUES (?, ?, 'ssh', 1, ?, 10, 11)",
|
||||
(
|
||||
"remote-1",
|
||||
"Storage",
|
||||
'{"host":"storage","port":2222,"username":"ops","ssh_private_key":"PRIVATE","ssh_private_key_passphrase":"phrase","password":"pw"}',
|
||||
),
|
||||
)
|
||||
conn.execute("INSERT INTO monitoring_machines VALUES (?, ?, 'local', 1, '{}', 10, 11)", ("local", "This machine"))
|
||||
conn.execute("INSERT INTO services VALUES ('task-service', 'ssh_tasks', 'Tasks', '{}', '{}', 1, 1, 1)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
store = SettingsStore(db_path)
|
||||
store.init_schema()
|
||||
remote = store.get_service("remote-1")
|
||||
assert remote and remote["service_type"] == "remote_machine"
|
||||
assert remote["config"] == {
|
||||
"host": "storage",
|
||||
"port": 2222,
|
||||
"username": "ops",
|
||||
"ssh_key_id": "legacy-key-remote-1",
|
||||
"timeout_seconds": 30,
|
||||
}
|
||||
assert decrypt_secrets(remote["secrets"]) == {"passphrase": "phrase", "password": "pw"}
|
||||
assert store.get_ssh_key("legacy-key-remote-1")["private_key"] == "PRIVATE"
|
||||
assert store.get_service("task-service")["service_type"] == "remote_machine"
|
||||
with store.connect() as check:
|
||||
assert (
|
||||
check.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='monitoring_machines'").fetchone()
|
||||
is None
|
||||
)
|
||||
store.init_schema()
|
||||
assert store.get_service("remote-1")["id"] == "remote-1"
|
||||
|
||||
|
||||
def test_migrates_task_default_service_id_to_service_id(tmp_path):
|
||||
db_path = tmp_path / "settings.sqlite"
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE saved_tasks (
|
||||
id TEXT PRIMARY KEY, name TEXT NOT NULL, task_type TEXT NOT NULL,
|
||||
content TEXT NOT NULL, enabled INTEGER NOT NULL, default_service_id TEXT NOT NULL,
|
||||
notes TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute("INSERT INTO saved_tasks VALUES ('task-1', 'Check', 'shell', 'true', 1, 'remote-1', '', 1, 1)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
store = SettingsStore(db_path)
|
||||
store.init_schema()
|
||||
assert store.get_task("task-1")["service_id"] == "remote-1"
|
||||
with store.connect() as check:
|
||||
columns = {row[1] for row in check.execute("PRAGMA table_info(saved_tasks)")}
|
||||
assert "service_id" in columns
|
||||
assert "default_service_id" not in columns
|
||||
@@ -63,7 +63,7 @@ def test_registry_contains_eight_service_types():
|
||||
"alertmanager",
|
||||
"jellyfin",
|
||||
"nextcloud",
|
||||
"ssh_tasks",
|
||||
"remote_machine",
|
||||
"backups",
|
||||
"authentik",
|
||||
"qbittorrent",
|
||||
@@ -113,7 +113,7 @@ def test_definitions_declare_widget_kinds():
|
||||
assert get_service_definition("nextcloud").widget_kinds == []
|
||||
assert get_service_definition("authentik").widget_kinds == []
|
||||
assert {wk.kind for wk in get_service_definition("backups").widget_kinds} == {"summary"}
|
||||
assert {wk.kind for wk in get_service_definition("ssh_tasks").widget_kinds} == {"task_output"}
|
||||
assert {wk.kind for wk in get_service_definition("remote_machine").widget_kinds} == {"task_output"}
|
||||
|
||||
|
||||
def test_widget_kind_lookup():
|
||||
@@ -206,7 +206,7 @@ def test_list_service_types(client):
|
||||
"nextcloud",
|
||||
"prometheus",
|
||||
"qbittorrent",
|
||||
"ssh_tasks",
|
||||
"remote_machine",
|
||||
}
|
||||
|
||||
|
||||
@@ -539,7 +539,7 @@ def test_cascade_delete_removes_harness_data_across_concerns(tmp_path, monkeypat
|
||||
def test_record_and_list_service_task_runs(client):
|
||||
store = app.dependency_overrides[get_settings_store]()
|
||||
service = store.upsert_service(
|
||||
{"service_type": "ssh_tasks", "name": "box", "config": {"host": "h"}, "enabled": True}
|
||||
{"service_type": "remote_machine", "name": "box", "config": {"host": "h"}, "enabled": True}
|
||||
)
|
||||
store.record_service_task_run(
|
||||
{
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
"""Tests for Prometheus Node Exporter target discovery."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
from media_library_viewer_api.services.targets import build_node_exporter_targets
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path: Path) -> SettingsStore:
|
||||
db = SettingsStore(tmp_path / "settings.sqlite")
|
||||
db.init_schema()
|
||||
return db
|
||||
|
||||
|
||||
class TestBuildNodeExporterTargets:
|
||||
def test_disabled_machine_excluded(self, store: SettingsStore):
|
||||
store.upsert_machine(
|
||||
{
|
||||
"name": "remote1",
|
||||
"mode": "ssh",
|
||||
"host": "10.0.0.5",
|
||||
"username": "u",
|
||||
"node_exporter_enabled": False,
|
||||
"node_exporter_port": 9200,
|
||||
}
|
||||
)
|
||||
assert build_node_exporter_targets(store) == []
|
||||
|
||||
def test_ssh_enabled_machine_included(self, store: SettingsStore):
|
||||
machine = store.upsert_machine(
|
||||
{
|
||||
"name": "remote1",
|
||||
"mode": "ssh",
|
||||
"host": "10.0.0.5",
|
||||
"username": "u",
|
||||
"node_exporter_enabled": True,
|
||||
"node_exporter_port": 9200,
|
||||
"node_exporter_scrape_host": "1.2.3.4",
|
||||
}
|
||||
)
|
||||
targets = build_node_exporter_targets(store)
|
||||
assert len(targets) == 1
|
||||
assert targets[0]["targets"] == ["1.2.3.4:9200"]
|
||||
assert targets[0]["labels"]["machine_id"] == machine["id"]
|
||||
assert targets[0]["labels"]["machine_name"] == "remote1"
|
||||
assert targets[0]["labels"]["job"] == "node-exporter-remote"
|
||||
|
||||
def test_scrape_host_defaults_to_machine_host(self, store: SettingsStore):
|
||||
store.upsert_machine(
|
||||
{
|
||||
"name": "remote2",
|
||||
"mode": "ssh",
|
||||
"host": "remote2.example.com",
|
||||
"username": "u",
|
||||
"node_exporter_enabled": True,
|
||||
"node_exporter_port": 9100,
|
||||
}
|
||||
)
|
||||
targets = build_node_exporter_targets(store)
|
||||
assert targets[0]["targets"] == ["remote2.example.com:9100"]
|
||||
|
||||
def test_local_machine_excluded(self, store: SettingsStore):
|
||||
store.upsert_machine(
|
||||
{
|
||||
"name": "This machine",
|
||||
"mode": "local",
|
||||
"host": "localhost",
|
||||
"username": "",
|
||||
"node_exporter_enabled": True,
|
||||
}
|
||||
)
|
||||
assert build_node_exporter_targets(store) == []
|
||||
|
||||
def test_missing_host_excluded(self, store: SettingsStore):
|
||||
store.upsert_machine(
|
||||
{
|
||||
"name": "remote3",
|
||||
"mode": "ssh",
|
||||
"host": "",
|
||||
"username": "u",
|
||||
"node_exporter_enabled": True,
|
||||
}
|
||||
)
|
||||
assert build_node_exporter_targets(store) == []
|
||||
@@ -437,18 +437,18 @@ async def test_ssh_task_adapter_missing_service():
|
||||
@pytest.mark.asyncio
|
||||
async def test_ssh_task_adapter_records_history_on_run(client):
|
||||
store = app.dependency_overrides[get_settings_store]()
|
||||
# Save a task and an ssh_tasks service instance.
|
||||
# Save a task and an remote_machine service instance.
|
||||
task = store.upsert_task(
|
||||
{
|
||||
"name": "echo",
|
||||
"task_type": "shell",
|
||||
"content": "echo hi",
|
||||
"enabled": True,
|
||||
"default_service_id": "",
|
||||
"service_id": "",
|
||||
}
|
||||
)
|
||||
service = store.upsert_service(
|
||||
{"service_type": "ssh_tasks", "name": "box", "config": {"host": "h", "username": "u"}, "enabled": True}
|
||||
{"service_type": "remote_machine", "name": "box", "config": {"host": "h", "username": "u"}, "enabled": True}
|
||||
)
|
||||
|
||||
fake_result = SimpleNamespace(exit_status=0, stdout="hi\n", stderr="")
|
||||
@@ -458,7 +458,7 @@ async def test_ssh_task_adapter_records_history_on_run(client):
|
||||
|
||||
adapter = SshTaskWidgetSource()
|
||||
service_record = ServiceRecord(
|
||||
id=service["id"], service_type="ssh_tasks", name="box", config={"host": "h", "username": "u"}
|
||||
id=service["id"], service_type="remote_machine", name="box", config={"host": "h", "username": "u"}
|
||||
)
|
||||
with (
|
||||
patch("media_library_viewer_api.widgets.sources.get_settings_store", return_value=store),
|
||||
|
||||
+15
-8
@@ -137,7 +137,7 @@ fully removed (web-ui-rework; see decision log 2026-06-17).
|
||||
### Remote Filesystem over SSH
|
||||
|
||||
- Connect to a remote media server via SSH.
|
||||
- Use strict SSH host key behavior, but synthesize and persist the managed `known_hosts` file from configured SSH machines instead of requiring users to mount their own `known_hosts` file.
|
||||
- Use strict SSH host key behavior, but synthesize and persist the managed `known_hosts` file from configured remote-machine services instead of requiring users to mount their own `known_hosts` file.
|
||||
- Browse remote directories and files rooted at a configurable default media path.
|
||||
- File browser handoff should map Jellyfin paths to `REMOTE_MEDIA_ROOT` when possible (for example `/media/...` -> `/srv/media/...` when root is `/srv/media`).
|
||||
- Media index paths should be stored in the SSH-visible form by default, using the same Jellyfin-to-SSH mapping so the Media tab and file browser agree on paths.
|
||||
@@ -200,7 +200,7 @@ fully removed (web-ui-rework; see decision log 2026-06-17).
|
||||
- Support OIDC login in the frontend using an OIDC client library, with backend JWT validation for protected API requests.
|
||||
- Persist frontend OIDC auth state across tab reloads by storing the OIDC user and request state in browser localStorage.
|
||||
- Provide Docker Compose deployment files at the repository root for production and local development. These deploy **only** the backend and frontend; Manage connects to *existing* Grafana/Prometheus/Alertmanager instances and never ships its own observability stack (see `docker-compose.observability.yml` for an optional standalone example).
|
||||
- SSH private keys should be managed as reusable saved secrets in Settings, independent of any one machine, and SSH machines should select from that saved-key list.
|
||||
- SSH private keys should be managed as reusable saved secrets in Settings, independent of any one machine, and remote machine services should select from that saved-key list.
|
||||
- The web UI should allow both importing an existing private key and generating a new SSH keypair for that saved-key list.
|
||||
- Saved SSH keys should display their derived public key, fingerprint, and machine usage count so administrators can audit them at a glance.
|
||||
- The app should support optional SSH private key passphrases alongside the stored key material.
|
||||
@@ -394,7 +394,7 @@ the widget/addon-pages model were removed. `MANAGE_ENCRYPTION_KEY` is now requir
|
||||
- 2026-05-06: Monitoring became machine-based: a Settings tab now persists local/remote machine definitions, and the Monitoring tab renders a section per configured machine so API-host and remote targets are handled through the same UI model.
|
||||
- 2026-05-06: Compose files were switched away from `env_file` and now rely on environment-variable interpolation, so deployments can be driven entirely by shell exports or inline environment values.
|
||||
- 2026-05-06: Monitoring endpoints now translate machine-specific transport/runtime failures into user-facing HTTP errors so a broken machine only affects its own section instead of taking down the whole Monitoring page.
|
||||
- 2026-05-06: Documentation now includes explicit Compose interpolation examples plus a monitoring-machine configuration workflow showing how to add local and SSH machines in the Settings tab.
|
||||
- 2026-05-06: Documentation now includes explicit Compose interpolation examples plus a monitoring-machine configuration workflow showing how to add local and remote machine services in the Settings tab.
|
||||
- 2026-05-06: Monitoring machine action history was added so each machine section can display recent operation results, durations, and failures alongside the charts.
|
||||
- 2026-05-06: Monitoring history collection was shifted to a backend-scheduled poller that reads the defined machines over SSH/local shell and stores snapshots in SQLite, avoiding any remote agent or push requirement.
|
||||
- 2026-05-06: The dashboard monitoring section was converted from summary cards into a table of all configured machines, paired with backend poller status so the whole fleet can be reviewed at a glance.
|
||||
@@ -415,18 +415,18 @@ the widget/addon-pages model were removed. `MANAGE_ENCRYPTION_KEY` is now requir
|
||||
- 2026-05-06: Application settings now include per-machine Jellyfin/Jellyseerr configuration and multi-select service roles so the UI can manage app hosts from the same machine registry.
|
||||
- 2026-05-06: The app shell now uses an Applications top-level tab with a Jellyfin subtab for media/library work and a placeholder Nextcloud subtab for future expansion.
|
||||
- 2026-05-06: The settings model now treats Jellyfin/Jellyseerr as machine-level configuration instead of global env-only values, so app hosts can be edited alongside other machine services.
|
||||
- 2026-05-06: The backend now synthesizes a managed `known_hosts` file from configured SSH machines at startup, avoiding a mounted SSH directory while keeping strict host-key verification enabled.
|
||||
- 2026-05-06: The backend now synthesizes a managed `known_hosts` file from configured remote-machine services at startup, avoiding a mounted SSH directory while keeping strict host-key verification enabled.
|
||||
- 2026-05-06: SSH credentials were moved toward reusable saved key records in Settings, so machines can point at a shared SSH key instead of storing their own duplicate private key text.
|
||||
- 2026-05-06: The Settings page now includes an SSH key registry UI with create/edit/delete flows and a generate-key action so users can make a reusable key directly in the web interface.
|
||||
- 2026-05-06: Saved SSH keys now surface a derived public key, fingerprint, and per-key machine usage count in the Settings UI for easier auditing.
|
||||
- 2026-05-06: The dev Compose stack now starts without any SSH key material at all unless a user later configures remote SSH machines.
|
||||
- 2026-05-06: The dev Compose stack now starts without any SSH key material at all unless a user later configures remote remote machine services.
|
||||
- 2026-05-06: The Settings page now exposes a protected local-database reset flow that requires several explicit acknowledgements and a typed confirmation phrase before it can delete the cached app databases.
|
||||
- 2026-05-06: The Actions page was redesigned into a compact tabbed workspace with a left tab rail of saved actions, and both new-action creation and editing now open in popups instead of inline forms.
|
||||
- 2026-05-07: Added a reusable dashboard shortcuts container with persisted records so the dashboard can link to external websites now and later support action/user shortcut types from the same model.
|
||||
- 2026-05-07: Dashboard shortcuts gained an optional icon/preview field so cards can be visually differentiated while keeping future shortcut types extensible.
|
||||
- 2026-05-07: The dashboard shortcut editor was tightened with compact type guidance and shorter helper text so the popup stays readable without wasting vertical space.
|
||||
- 2026-05-07: SSH key records should persist and display the derived public key and fingerprint, not just the private key blob, so imports and generated keys are auditable without recomputation.
|
||||
- 2026-05-07: SSH machine creation/editing should present a saved-key dropdown and warn when no SSH keys exist yet, instead of forcing manual key-id entry.
|
||||
- 2026-05-07: remote machine service creation/editing should present a saved-key dropdown and warn when no SSH keys exist yet, instead of forcing manual key-id entry.
|
||||
- 2026-05-07: Saved task runs should return structured failure output for local execution problems instead of surfacing a generic 500 error.
|
||||
- 2026-05-07: SSH dependency resolution should keep its cached tuple shape aligned with the legacy and machine-specific SSH settings so SSH clients can be created without tuple-unpack crashes.
|
||||
- 2026-05-07: Machine creation was adjusted so dialog edits are controlled by the parent form state, ensuring all entered fields are actually saved.
|
||||
@@ -445,13 +445,13 @@ the widget/addon-pages model were removed. `MANAGE_ENCRYPTION_KEY` is now requir
|
||||
- 2026-05-06: The File Browser was reworked into Browser / Media info / Jobs subtabs.
|
||||
- 2026-05-06: The app shell received a small density pass that tightened container padding and tab widths to make the whole site feel more compact.
|
||||
- 2026-05-06: The tab rails across Actions, Monitoring, Settings, and Files were restyled to be more enterprise-console-like with compact pills, clearer active states, and reduced visual noise.
|
||||
- 2026-05-06: Added an Actions tab for saved server tasks, with backend persistence, per-task run history, and support for shell/Python task types on either local or SSH machines.
|
||||
- 2026-05-06: Added an Actions tab for saved server tasks, with backend persistence, per-task run history, and support for shell/Python task types on either local or remote machine services.
|
||||
- 2026-05-06: Reusable dialog footers now keep cancel on the left and confirm on the right, and hover edit buttons now appear on the right edge of editable list rows in Actions and Settings.
|
||||
- 2026-05-06: Library stats, Jellyfin activity, and Monitoring overview now use shared section-container patterns so subcontainers stay consistent across the app.
|
||||
- 2026-05-07: The app versioning scheme should be hybrid: auto-detect package/build metadata when available, but allow explicit overrides for deployments that need fixed labels.
|
||||
- 2026-05-07: The shell should display both frontend and backend version labels so deployed builds are easy to identify without opening a separate diagnostics screen.
|
||||
- 2026-05-07: SSH host verification should use trust-on-first-use for new machines by recording the first observed host key into the backend-managed known_hosts file, while still rejecting later key mismatches.
|
||||
- 2026-05-07: The SSH machine editor should expose a validation button that tests banner/auth flow and records the host key before save so users get clear feedback when a host is unreachable.
|
||||
- 2026-05-07: The remote machine service editor should expose a validation button that tests banner/auth flow and records the host key before save so users get clear feedback when a host is unreachable.
|
||||
- 2026-05-07: Saving a monitoring-capable machine should validate the banner/auth flow, update the backend-managed known_hosts entry for the current host, and start the remote resource collector so charts populate without a separate manual step.
|
||||
- 2026-05-07: Machine settings should visually separate Connection, Monitoring / Files, Jellyfin, Jellyseerr, and Notes into clearly labeled sections.
|
||||
|
||||
@@ -542,3 +542,10 @@ unchanged.
|
||||
|
||||
- Below `md`, edit affordances are always visible (not hover-gated). At `md:`
|
||||
and above, the desktop hover-reveal aesthetic is preserved.
|
||||
|
||||
### Remote-machine services
|
||||
|
||||
- Remote hosts are configured as enabled `remote_machine` services under Settings > Services, with host, port, username, saved SSH-key reference, timeout, and encrypted passphrase/password secrets.
|
||||
- Files and Actions require an explicit remote-machine `service_id`; saved task output and task runs remain service-scoped.
|
||||
- Legacy SSH task services and SSH machine records migrate into remote-machine services. The local legacy placeholder is not migrated; the saved SSH-key registry is preserved.
|
||||
- Manage does not discover or configure Node Exporter targets. Prometheus and Alertmanager remain independently configured services.
|
||||
|
||||
+229
-259
@@ -3,312 +3,282 @@
|
||||
*/
|
||||
|
||||
import type {
|
||||
MediaCounts,
|
||||
LibraryCount,
|
||||
UserDirectoryResponse,
|
||||
UserMessageResponse,
|
||||
UserMessageQueueStatus,
|
||||
NowPlayingSession,
|
||||
AppVersionInfo,
|
||||
SSHKey,
|
||||
SSHKeyInput,
|
||||
SSHKeyGenerated,
|
||||
SavedTask,
|
||||
SavedTaskInput,
|
||||
SavedTaskRun,
|
||||
MonitoringMachine,
|
||||
MonitoringMachineInput,
|
||||
MediaIndexStatus,
|
||||
MediaIndexActionResponse,
|
||||
MediaQueryResponse,
|
||||
DirectoryListing,
|
||||
JobTemplate,
|
||||
JobResult,
|
||||
ResolvedPath,
|
||||
ResetLocalDatabaseInput,
|
||||
ResetLocalDatabaseResponse,
|
||||
SSHValidationResult,
|
||||
DashboardShortcut,
|
||||
DashboardShortcutInput,
|
||||
AlertmanagerAlertSummary,
|
||||
AlertmanagerStatus,
|
||||
PrometheusStatus,
|
||||
PrometheusTarget,
|
||||
MediaCounts,
|
||||
LibraryCount,
|
||||
UserDirectoryResponse,
|
||||
UserMessageResponse,
|
||||
UserMessageQueueStatus,
|
||||
NowPlayingSession,
|
||||
AppVersionInfo,
|
||||
SSHKey,
|
||||
SSHKeyInput,
|
||||
SSHKeyGenerated,
|
||||
SavedTask,
|
||||
SavedTaskInput,
|
||||
SavedTaskRun,
|
||||
MediaIndexStatus,
|
||||
MediaIndexActionResponse,
|
||||
MediaQueryResponse,
|
||||
DirectoryListing,
|
||||
JobTemplate,
|
||||
JobResult,
|
||||
ResolvedPath,
|
||||
ResetLocalDatabaseInput,
|
||||
ResetLocalDatabaseResponse,
|
||||
DashboardShortcut,
|
||||
DashboardShortcutInput,
|
||||
AlertmanagerAlertSummary,
|
||||
AlertmanagerStatus,
|
||||
PrometheusStatus,
|
||||
} from "../types";
|
||||
import {
|
||||
buildHeaders,
|
||||
buildUrl,
|
||||
del,
|
||||
get,
|
||||
post,
|
||||
postForm,
|
||||
readErrorDetail,
|
||||
buildHeaders,
|
||||
buildUrl,
|
||||
del,
|
||||
get,
|
||||
post,
|
||||
postForm,
|
||||
readErrorDetail,
|
||||
} from "./shared";
|
||||
|
||||
// Dashboard (Jellyfin-backed; selected via jellyfin_service_id)
|
||||
export const fetchCounts = (jellyfinServiceId?: string) =>
|
||||
get<MediaCounts>(
|
||||
"/api/dashboard/counts",
|
||||
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
|
||||
);
|
||||
get<MediaCounts>(
|
||||
"/api/dashboard/counts",
|
||||
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
|
||||
);
|
||||
export const fetchLibraries = (jellyfinServiceId?: string) =>
|
||||
get<LibraryCount[]>(
|
||||
"/api/dashboard/libraries",
|
||||
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
|
||||
);
|
||||
get<LibraryCount[]>(
|
||||
"/api/dashboard/libraries",
|
||||
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
|
||||
);
|
||||
export const fetchActivity = (jellyfinServiceId?: string) =>
|
||||
get<NowPlayingSession[]>(
|
||||
"/api/dashboard/activity",
|
||||
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
|
||||
);
|
||||
get<NowPlayingSession[]>(
|
||||
"/api/dashboard/activity",
|
||||
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
|
||||
);
|
||||
export const fetchUsers = (jellyfinServiceId?: string) =>
|
||||
get<UserDirectoryResponse>(
|
||||
"/api/users",
|
||||
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
|
||||
);
|
||||
get<UserDirectoryResponse>(
|
||||
"/api/users",
|
||||
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
|
||||
);
|
||||
|
||||
// Backward-compatible alias used by older hooks/components.
|
||||
export const fetchNowPlaying = fetchActivity;
|
||||
|
||||
// Monitoring
|
||||
export const fetchMonitoringMachines = () =>
|
||||
get<MonitoringMachine[]>("/api/monitoring/machines");
|
||||
// General
|
||||
export const fetchAppVersion = () => get<AppVersionInfo>("/api/version");
|
||||
export const fetchDashboardShortcuts = () =>
|
||||
get<DashboardShortcut[]>("/api/dashboard/shortcuts");
|
||||
get<DashboardShortcut[]>("/api/dashboard/shortcuts");
|
||||
export const saveDashboardShortcut = (shortcut: DashboardShortcutInput) =>
|
||||
fetch(
|
||||
buildUrl(
|
||||
shortcut.id
|
||||
? `/api/dashboard/shortcuts/${encodeURIComponent(shortcut.id)}`
|
||||
: "/api/dashboard/shortcuts",
|
||||
),
|
||||
{
|
||||
method: shortcut.id ? "PUT" : "POST",
|
||||
headers: buildHeaders(true),
|
||||
body: JSON.stringify(shortcut),
|
||||
},
|
||||
).then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
|
||||
}
|
||||
return response.json() as Promise<DashboardShortcut>;
|
||||
});
|
||||
fetch(
|
||||
buildUrl(
|
||||
shortcut.id
|
||||
? `/api/dashboard/shortcuts/${encodeURIComponent(shortcut.id)}`
|
||||
: "/api/dashboard/shortcuts",
|
||||
),
|
||||
{
|
||||
method: shortcut.id ? "PUT" : "POST",
|
||||
headers: buildHeaders(true),
|
||||
body: JSON.stringify(shortcut),
|
||||
},
|
||||
).then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
|
||||
}
|
||||
return response.json() as Promise<DashboardShortcut>;
|
||||
});
|
||||
export const deleteDashboardShortcut = (shortcutId: string) =>
|
||||
del<{ status: string }>(
|
||||
`/api/dashboard/shortcuts/${encodeURIComponent(shortcutId)}`,
|
||||
);
|
||||
export const fetchMonitoringSettings = () =>
|
||||
get<MonitoringMachine[]>("/api/settings/machines");
|
||||
del<{ status: string }>(
|
||||
`/api/dashboard/shortcuts/${encodeURIComponent(shortcutId)}`,
|
||||
);
|
||||
export const fetchSSHKeys = () => get<SSHKey[]>("/api/settings/ssh-keys");
|
||||
export const generateSSHKey = (payload: {
|
||||
name: string;
|
||||
passphrase: string;
|
||||
notes: string;
|
||||
bits?: number;
|
||||
name: string;
|
||||
passphrase: string;
|
||||
notes: string;
|
||||
bits?: number;
|
||||
}) => post<SSHKeyGenerated>("/api/settings/ssh-keys/generate", payload);
|
||||
export const saveSSHKey = (key: SSHKeyInput) =>
|
||||
fetch(
|
||||
buildUrl(
|
||||
key.id
|
||||
? `/api/settings/ssh-keys/${encodeURIComponent(key.id)}`
|
||||
: "/api/settings/ssh-keys",
|
||||
),
|
||||
{
|
||||
method: key.id ? "PUT" : "POST",
|
||||
headers: buildHeaders(true),
|
||||
body: JSON.stringify(key),
|
||||
},
|
||||
).then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
|
||||
}
|
||||
return response.json() as Promise<SSHKey>;
|
||||
});
|
||||
fetch(
|
||||
buildUrl(
|
||||
key.id
|
||||
? `/api/settings/ssh-keys/${encodeURIComponent(key.id)}`
|
||||
: "/api/settings/ssh-keys",
|
||||
),
|
||||
{
|
||||
method: key.id ? "PUT" : "POST",
|
||||
headers: buildHeaders(true),
|
||||
body: JSON.stringify(key),
|
||||
},
|
||||
).then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
|
||||
}
|
||||
return response.json() as Promise<SSHKey>;
|
||||
});
|
||||
export const deleteSSHKey = (keyId: string) =>
|
||||
del<{ status: string }>(
|
||||
`/api/settings/ssh-keys/${encodeURIComponent(keyId)}`,
|
||||
);
|
||||
del<{ status: string }>(
|
||||
`/api/settings/ssh-keys/${encodeURIComponent(keyId)}`,
|
||||
);
|
||||
|
||||
export const fetchSavedTasks = () => get<SavedTask[]>("/api/tasks");
|
||||
export const fetchSavedTaskRuns = (taskId: string, limit = 10) =>
|
||||
get<{ items: SavedTaskRun[]; total: number }>(
|
||||
`/api/tasks/${encodeURIComponent(taskId)}/runs`,
|
||||
{
|
||||
limit: String(limit),
|
||||
},
|
||||
);
|
||||
export const fetchSavedTasks = (serviceId: string) =>
|
||||
get<SavedTask[]>("/api/tasks", { service_id: serviceId });
|
||||
export const fetchSavedTaskRuns = (
|
||||
taskId: string,
|
||||
serviceId: string,
|
||||
limit = 10,
|
||||
) =>
|
||||
get<{ items: SavedTaskRun[]; total: number }>(
|
||||
`/api/tasks/${encodeURIComponent(taskId)}/runs`,
|
||||
{
|
||||
service_id: serviceId,
|
||||
limit: String(limit),
|
||||
},
|
||||
);
|
||||
export const saveTask = (task: SavedTaskInput) =>
|
||||
fetch(
|
||||
buildUrl(
|
||||
task.id ? `/api/tasks/${encodeURIComponent(task.id)}` : "/api/tasks",
|
||||
),
|
||||
{
|
||||
method: task.id ? "PUT" : "POST",
|
||||
headers: buildHeaders(true),
|
||||
body: JSON.stringify(task),
|
||||
},
|
||||
).then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
|
||||
}
|
||||
return response.json() as Promise<SavedTask>;
|
||||
});
|
||||
export const deleteTask = (taskId: string) =>
|
||||
del<{ status: string }>(`/api/tasks/${encodeURIComponent(taskId)}`);
|
||||
export const runTask = (taskId: string, serviceId?: string) =>
|
||||
post<{
|
||||
task_id: string;
|
||||
task_name: string;
|
||||
service_id: string;
|
||||
service_name: string;
|
||||
task_type: string;
|
||||
exit_status: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}>(
|
||||
serviceId
|
||||
? `/api/tasks/run?service_id=${encodeURIComponent(serviceId)}`
|
||||
: "/api/tasks/run",
|
||||
{ task_id: taskId },
|
||||
);
|
||||
fetch(
|
||||
buildUrl(
|
||||
task.id ? `/api/tasks/${encodeURIComponent(task.id)}` : "/api/tasks",
|
||||
),
|
||||
{
|
||||
method: task.id ? "PUT" : "POST",
|
||||
headers: buildHeaders(true),
|
||||
body: JSON.stringify(task),
|
||||
},
|
||||
).then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
|
||||
}
|
||||
return response.json() as Promise<SavedTask>;
|
||||
});
|
||||
export const deleteTask = (taskId: string, serviceId: string) =>
|
||||
del<{ status: string }>(
|
||||
`/api/tasks/${encodeURIComponent(taskId)}?service_id=${encodeURIComponent(serviceId)}`,
|
||||
);
|
||||
export const runTask = (taskId: string, serviceId: string) =>
|
||||
post<{
|
||||
task_id: string;
|
||||
task_name: string;
|
||||
service_id: string;
|
||||
service_name: string;
|
||||
task_type: string;
|
||||
exit_status: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}>(`/api/tasks/run?service_id=${encodeURIComponent(serviceId)}`, {
|
||||
task_id: taskId,
|
||||
});
|
||||
|
||||
export const saveMonitoringMachine = (machine: MonitoringMachineInput) =>
|
||||
fetch(
|
||||
buildUrl(
|
||||
machine.id
|
||||
? `/api/settings/machines/${encodeURIComponent(machine.id)}`
|
||||
: "/api/settings/machines",
|
||||
),
|
||||
{
|
||||
method: machine.id ? "PUT" : "POST",
|
||||
headers: buildHeaders(true),
|
||||
body: JSON.stringify(machine),
|
||||
},
|
||||
).then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
|
||||
}
|
||||
return response.json() as Promise<MonitoringMachine>;
|
||||
});
|
||||
export const testMonitoringMachineSSH = (machine: MonitoringMachineInput) =>
|
||||
post<SSHValidationResult>("/api/settings/machines/test-ssh", machine);
|
||||
export const deleteMonitoringMachine = (machineId: string) =>
|
||||
del<{ status: string }>(
|
||||
`/api/settings/machines/${encodeURIComponent(machineId)}`,
|
||||
);
|
||||
export const resetLocalDatabase = (payload: ResetLocalDatabaseInput) =>
|
||||
post<ResetLocalDatabaseResponse>(
|
||||
"/api/settings/reset-local-database",
|
||||
payload,
|
||||
);
|
||||
post<ResetLocalDatabaseResponse>(
|
||||
"/api/settings/reset-local-database",
|
||||
payload,
|
||||
);
|
||||
|
||||
// Media
|
||||
export const fetchMediaStatus = (jellyfinServiceId?: string) =>
|
||||
get<MediaIndexStatus>(
|
||||
"/api/media/status",
|
||||
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
|
||||
);
|
||||
get<MediaIndexStatus>(
|
||||
"/api/media/status",
|
||||
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
|
||||
);
|
||||
export const buildMediaIndex = (jellyfinServiceId?: string) =>
|
||||
post<MediaIndexActionResponse>(
|
||||
jellyfinServiceId
|
||||
? `/api/media/build?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
|
||||
: "/api/media/build",
|
||||
);
|
||||
post<MediaIndexActionResponse>(
|
||||
jellyfinServiceId
|
||||
? `/api/media/build?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
|
||||
: "/api/media/build",
|
||||
);
|
||||
export const stopMediaIndexBuild = (jellyfinServiceId?: string) =>
|
||||
post<MediaIndexActionResponse>(
|
||||
jellyfinServiceId
|
||||
? `/api/media/stop?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
|
||||
: "/api/media/stop",
|
||||
);
|
||||
post<MediaIndexActionResponse>(
|
||||
jellyfinServiceId
|
||||
? `/api/media/stop?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
|
||||
: "/api/media/stop",
|
||||
);
|
||||
export const forceStopMediaIndexBuild = (jellyfinServiceId?: string) =>
|
||||
post<MediaIndexActionResponse>(
|
||||
jellyfinServiceId
|
||||
? `/api/media/force-stop?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
|
||||
: "/api/media/force-stop",
|
||||
);
|
||||
post<MediaIndexActionResponse>(
|
||||
jellyfinServiceId
|
||||
? `/api/media/force-stop?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
|
||||
: "/api/media/force-stop",
|
||||
);
|
||||
export const queryMedia = (params: {
|
||||
libraries?: string;
|
||||
types?: string;
|
||||
search?: string;
|
||||
hdr_filter?: string;
|
||||
sort_key?: string;
|
||||
sort_order?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
jellyfinServiceId?: string;
|
||||
libraries?: string;
|
||||
types?: string;
|
||||
search?: string;
|
||||
hdr_filter?: string;
|
||||
sort_key?: string;
|
||||
sort_order?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
jellyfinServiceId?: string;
|
||||
}) =>
|
||||
get<MediaQueryResponse>("/api/media/query", {
|
||||
libraries: params.libraries || "",
|
||||
types: params.types || "Movie,Episode",
|
||||
search: params.search || "",
|
||||
hdr_filter: params.hdr_filter || "All",
|
||||
sort_key: params.sort_key || "title",
|
||||
sort_order: params.sort_order || "Ascending",
|
||||
limit: String(params.limit || 100),
|
||||
offset: String(params.offset || 0),
|
||||
...(params.jellyfinServiceId
|
||||
? { jellyfin_service_id: params.jellyfinServiceId }
|
||||
: {}),
|
||||
});
|
||||
get<MediaQueryResponse>("/api/media/query", {
|
||||
libraries: params.libraries || "",
|
||||
types: params.types || "Movie,Episode",
|
||||
search: params.search || "",
|
||||
hdr_filter: params.hdr_filter || "All",
|
||||
sort_key: params.sort_key || "title",
|
||||
sort_order: params.sort_order || "Ascending",
|
||||
limit: String(params.limit || 100),
|
||||
offset: String(params.offset || 0),
|
||||
...(params.jellyfinServiceId
|
||||
? { jellyfin_service_id: params.jellyfinServiceId }
|
||||
: {}),
|
||||
});
|
||||
|
||||
// Files
|
||||
export const fetchDirectoryListing = (path: string, machineId?: string) =>
|
||||
get<DirectoryListing>("/api/files/list", {
|
||||
path,
|
||||
...(machineId ? { machine_id: machineId } : {}),
|
||||
});
|
||||
export const fetchFfprobe = (path: string, machineId?: string) =>
|
||||
get<Record<string, unknown>>("/api/files/ffprobe", {
|
||||
path,
|
||||
...(machineId ? { machine_id: machineId } : {}),
|
||||
});
|
||||
export const fetchStat = (path: string, machineId?: string) =>
|
||||
get<{ path: string; output: string }>("/api/files/stat", {
|
||||
path,
|
||||
...(machineId ? { machine_id: machineId } : {}),
|
||||
});
|
||||
export const resolvePath = (path: string, machineId?: string) =>
|
||||
get<ResolvedPath>("/api/files/resolve-path", {
|
||||
path,
|
||||
...(machineId ? { machine_id: machineId } : {}),
|
||||
});
|
||||
export const fetchDirectoryListing = (path: string, serviceId?: string) =>
|
||||
get<DirectoryListing>("/api/files/list", {
|
||||
path,
|
||||
...(serviceId ? { service_id: serviceId } : {}),
|
||||
});
|
||||
export const fetchFfprobe = (path: string, serviceId?: string) =>
|
||||
get<Record<string, unknown>>("/api/files/ffprobe", {
|
||||
path,
|
||||
...(serviceId ? { service_id: serviceId } : {}),
|
||||
});
|
||||
export const fetchStat = (path: string, serviceId?: string) =>
|
||||
get<{ path: string; output: string }>("/api/files/stat", {
|
||||
path,
|
||||
...(serviceId ? { service_id: serviceId } : {}),
|
||||
});
|
||||
export const resolvePath = (path: string, serviceId?: string) =>
|
||||
get<ResolvedPath>("/api/files/resolve-path", {
|
||||
path,
|
||||
...(serviceId ? { service_id: serviceId } : {}),
|
||||
});
|
||||
|
||||
// Jobs
|
||||
export const fetchJobTemplates = () =>
|
||||
get<JobTemplate[]>("/api/jobs/templates");
|
||||
export const runJob = (jobKey: string, path: string, machineId?: string) =>
|
||||
post<JobResult>(
|
||||
machineId
|
||||
? `/api/jobs/run?machine_id=${encodeURIComponent(machineId)}`
|
||||
: "/api/jobs/run",
|
||||
{ job_key: jobKey, path },
|
||||
);
|
||||
get<JobTemplate[]>("/api/jobs/templates");
|
||||
export const runJob = (jobKey: string, path: string, serviceId?: string) =>
|
||||
post<JobResult>(
|
||||
serviceId
|
||||
? `/api/jobs/run?service_id=${encodeURIComponent(serviceId)}`
|
||||
: "/api/jobs/run",
|
||||
{ job_key: jobKey, path },
|
||||
);
|
||||
|
||||
export const fetchUserMessageQueueStatus = () =>
|
||||
get<UserMessageQueueStatus>("/api/users/message/status");
|
||||
get<UserMessageQueueStatus>("/api/users/message/status");
|
||||
|
||||
export const sendUserMessage = (formData: FormData) =>
|
||||
postForm<UserMessageResponse>("/api/users/message", formData);
|
||||
postForm<UserMessageResponse>("/api/users/message", formData);
|
||||
|
||||
// Observability summary endpoints
|
||||
export const fetchAlertmanagerAlerts = (serviceId?: string) =>
|
||||
get<AlertmanagerAlertSummary>(
|
||||
"/api/monitoring/alerts",
|
||||
serviceId ? { service_id: serviceId } : undefined,
|
||||
);
|
||||
get<AlertmanagerAlertSummary>(
|
||||
"/api/monitoring/alerts",
|
||||
serviceId ? { service_id: serviceId } : undefined,
|
||||
);
|
||||
|
||||
export const fetchAlertmanagerStatus = (serviceId?: string) =>
|
||||
get<AlertmanagerStatus>(
|
||||
"/api/monitoring/alertmanager-status",
|
||||
serviceId ? { service_id: serviceId } : undefined,
|
||||
);
|
||||
get<AlertmanagerStatus>(
|
||||
"/api/monitoring/alertmanager-status",
|
||||
serviceId ? { service_id: serviceId } : undefined,
|
||||
);
|
||||
|
||||
export const fetchPrometheusStatus = (serviceId?: string) =>
|
||||
get<PrometheusStatus>(
|
||||
"/api/monitoring/prometheus-status",
|
||||
serviceId ? { service_id: serviceId } : undefined,
|
||||
);
|
||||
|
||||
export const fetchPrometheusTargets = () =>
|
||||
get<PrometheusTarget[]>("/api/monitoring/prometheus-targets");
|
||||
get<PrometheusStatus>(
|
||||
"/api/monitoring/prometheus-status",
|
||||
serviceId ? { service_id: serviceId } : undefined,
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,49 +1,49 @@
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
fetchDirectoryListing,
|
||||
fetchFfprobe,
|
||||
fetchStat,
|
||||
fetchJobTemplates,
|
||||
runJob,
|
||||
fetchDirectoryListing,
|
||||
fetchFfprobe,
|
||||
fetchStat,
|
||||
fetchJobTemplates,
|
||||
runJob,
|
||||
} from "../api/client";
|
||||
|
||||
export function useDirectoryListing(path: string, machineId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ["files", "list", path, machineId ?? "default"],
|
||||
queryFn: () => fetchDirectoryListing(path, machineId),
|
||||
enabled: !!path,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
export function useDirectoryListing(path: string, serviceId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ["files", "list", path, serviceId ?? "default"],
|
||||
queryFn: () => fetchDirectoryListing(path, serviceId),
|
||||
enabled: !!path,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useFfprobe(path: string, enabled = false, machineId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ["files", "ffprobe", path, machineId ?? "default"],
|
||||
queryFn: () => fetchFfprobe(path, machineId),
|
||||
enabled: enabled && !!path,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
export function useFfprobe(path: string, enabled = false, serviceId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ["files", "ffprobe", path, serviceId ?? "default"],
|
||||
queryFn: () => fetchFfprobe(path, serviceId),
|
||||
enabled: enabled && !!path,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useStat(path: string, enabled = false, machineId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ["files", "stat", path, machineId ?? "default"],
|
||||
queryFn: () => fetchStat(path, machineId),
|
||||
enabled: enabled && !!path,
|
||||
});
|
||||
export function useStat(path: string, enabled = false, serviceId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ["files", "stat", path, serviceId ?? "default"],
|
||||
queryFn: () => fetchStat(path, serviceId),
|
||||
enabled: enabled && !!path,
|
||||
});
|
||||
}
|
||||
|
||||
export function useJobTemplates() {
|
||||
return useQuery({
|
||||
queryKey: ["jobs", "templates"],
|
||||
queryFn: fetchJobTemplates,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
return useQuery({
|
||||
queryKey: ["jobs", "templates"],
|
||||
queryFn: fetchJobTemplates,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRunJob(machineId?: string) {
|
||||
return useMutation({
|
||||
mutationFn: ({ jobKey, path }: { jobKey: string; path: string }) =>
|
||||
runJob(jobKey, path, machineId),
|
||||
});
|
||||
export function useRunJob(serviceId?: string) {
|
||||
return useMutation({
|
||||
mutationFn: ({ jobKey, path }: { jobKey: string; path: string }) =>
|
||||
runJob(jobKey, path, serviceId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,58 +1,36 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
fetchAlertmanagerAlerts,
|
||||
fetchAlertmanagerStatus,
|
||||
fetchPrometheusStatus,
|
||||
fetchPrometheusTargets,
|
||||
fetchMonitoringMachines,
|
||||
fetchAlertmanagerAlerts,
|
||||
fetchAlertmanagerStatus,
|
||||
fetchPrometheusStatus,
|
||||
} from "../api/client";
|
||||
|
||||
export function useAlertmanagerAlerts(serviceId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ["observability", "alerts", serviceId ?? ""],
|
||||
queryFn: () => fetchAlertmanagerAlerts(serviceId),
|
||||
retry: 2,
|
||||
staleTime: 10_000,
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
return useQuery({
|
||||
queryKey: ["observability", "alerts", serviceId ?? ""],
|
||||
queryFn: () => fetchAlertmanagerAlerts(serviceId),
|
||||
retry: 2,
|
||||
staleTime: 10_000,
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAlertmanagerStatus(serviceId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ["observability", "alertmanager-status", serviceId ?? ""],
|
||||
queryFn: () => fetchAlertmanagerStatus(serviceId),
|
||||
retry: 2,
|
||||
staleTime: 10_000,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
return useQuery({
|
||||
queryKey: ["observability", "alertmanager-status", serviceId ?? ""],
|
||||
queryFn: () => fetchAlertmanagerStatus(serviceId),
|
||||
retry: 2,
|
||||
staleTime: 10_000,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePrometheusStatus(serviceId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ["observability", "prometheus-status", serviceId ?? ""],
|
||||
queryFn: () => fetchPrometheusStatus(serviceId),
|
||||
retry: 2,
|
||||
staleTime: 10_000,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePrometheusTargets() {
|
||||
return useQuery({
|
||||
queryKey: ["observability", "prometheus-targets"],
|
||||
queryFn: fetchPrometheusTargets,
|
||||
retry: 2,
|
||||
staleTime: 10_000,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMonitoringMachines() {
|
||||
return useQuery({
|
||||
queryKey: ["monitoring", "machines"],
|
||||
queryFn: fetchMonitoringMachines,
|
||||
retry: 2,
|
||||
staleTime: 10_000,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
return useQuery({
|
||||
queryKey: ["observability", "prometheus-status", serviceId ?? ""],
|
||||
queryFn: () => fetchPrometheusStatus(serviceId),
|
||||
retry: 2,
|
||||
staleTime: 10_000,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,168 +1,132 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
deleteMonitoringMachine,
|
||||
deleteSSHKey,
|
||||
fetchMonitoringSettings,
|
||||
fetchSSHKeys,
|
||||
fetchSavedTaskRuns,
|
||||
fetchSavedTasks,
|
||||
generateSSHKey,
|
||||
resetLocalDatabase,
|
||||
saveMonitoringMachine,
|
||||
saveSSHKey,
|
||||
saveTask,
|
||||
deleteTask,
|
||||
runTask,
|
||||
testMonitoringMachineSSH,
|
||||
deleteSSHKey,
|
||||
fetchSSHKeys,
|
||||
fetchSavedTaskRuns,
|
||||
fetchSavedTasks,
|
||||
generateSSHKey,
|
||||
resetLocalDatabase,
|
||||
saveSSHKey,
|
||||
saveTask,
|
||||
deleteTask,
|
||||
runTask,
|
||||
} from "../api/client";
|
||||
import type {
|
||||
MonitoringMachineInput,
|
||||
ResetLocalDatabaseInput,
|
||||
SavedTaskInput,
|
||||
SSHKeyInput,
|
||||
SSHValidationResult,
|
||||
ResetLocalDatabaseInput,
|
||||
SavedTaskInput,
|
||||
SSHKeyInput,
|
||||
} from "../types";
|
||||
|
||||
export function useMonitoringSettings() {
|
||||
return useQuery({
|
||||
queryKey: ["settings", "monitoring-machines"],
|
||||
queryFn: fetchMonitoringSettings,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSSHKeys() {
|
||||
return useQuery({
|
||||
queryKey: ["settings", "ssh-keys"],
|
||||
queryFn: fetchSSHKeys,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
return useQuery({
|
||||
queryKey: ["settings", "ssh-keys"],
|
||||
queryFn: fetchSSHKeys,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useGenerateSSHKey() {
|
||||
return useMutation({
|
||||
mutationFn: (payload: {
|
||||
name: string;
|
||||
passphrase: string;
|
||||
notes: string;
|
||||
bits?: number;
|
||||
}) => generateSSHKey(payload),
|
||||
});
|
||||
return useMutation({
|
||||
mutationFn: (payload: {
|
||||
name: string;
|
||||
passphrase: string;
|
||||
notes: string;
|
||||
bits?: number;
|
||||
}) => generateSSHKey(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveSSHKey() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (key: SSHKeyInput) => saveSSHKey(key),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
},
|
||||
});
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (key: SSHKeyInput) => saveSSHKey(key),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteSSHKey() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (keyId: string) => deleteSSHKey(keyId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["monitoring"] });
|
||||
},
|
||||
});
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (keyId: string) => deleteSSHKey(keyId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["monitoring"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTasks() {
|
||||
return useQuery({
|
||||
queryKey: ["tasks"],
|
||||
queryFn: fetchSavedTasks,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
export function useTasks(serviceId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ["tasks", serviceId ?? "none"],
|
||||
queryFn: () => fetchSavedTasks(serviceId ?? ""),
|
||||
enabled: Boolean(serviceId),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTaskRuns(taskId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ["tasks", taskId ?? "none", "runs"],
|
||||
queryFn: () => fetchSavedTaskRuns(taskId ?? ""),
|
||||
enabled: Boolean(taskId),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
export function useTaskRuns(taskId?: string, serviceId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ["tasks", serviceId ?? "none", taskId ?? "none", "runs"],
|
||||
queryFn: () => fetchSavedTaskRuns(taskId ?? "", serviceId ?? ""),
|
||||
enabled: Boolean(taskId && serviceId),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveTask() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (task: SavedTaskInput) => saveTask(task),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
},
|
||||
});
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (task: SavedTaskInput) => saveTask(task),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteTask() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (taskId: string) => deleteTask(taskId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
},
|
||||
});
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
taskId,
|
||||
serviceId,
|
||||
}: {
|
||||
taskId: string;
|
||||
serviceId: string;
|
||||
}) => deleteTask(taskId, serviceId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRunTask() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
taskId,
|
||||
serviceId,
|
||||
}: {
|
||||
taskId: string;
|
||||
serviceId?: string;
|
||||
}) => runTask(taskId, serviceId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveMonitoringMachine() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (machine: MonitoringMachineInput) =>
|
||||
saveMonitoringMachine(machine),
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["monitoring"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTestMonitoringMachineSSH() {
|
||||
return useMutation<SSHValidationResult, Error, MonitoringMachineInput>({
|
||||
mutationFn: testMonitoringMachineSSH,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteMonitoringMachine() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (machineId: string) => deleteMonitoringMachine(machineId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["monitoring"] });
|
||||
},
|
||||
});
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
taskId,
|
||||
serviceId,
|
||||
}: {
|
||||
taskId: string;
|
||||
serviceId: string;
|
||||
}) => runTask(taskId, serviceId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useResetLocalDatabase() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (payload: ResetLocalDatabaseInput) =>
|
||||
resetLocalDatabase(payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["monitoring"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["media"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
},
|
||||
});
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (payload: ResetLocalDatabaseInput) =>
|
||||
resetLocalDatabase(payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["monitoring"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["media"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { configuredNavEntries, SERVICE_TYPE_NAV_ENTRIES } from "../navEntries";
|
||||
|
||||
describe("navEntries", () => {
|
||||
@@ -13,37 +13,44 @@ describe("navEntries", () => {
|
||||
expect(entries[0].path).toBe("/services/jellyfin");
|
||||
});
|
||||
|
||||
it("returns one SSH Tasks entry when ssh_tasks is configured", () => {
|
||||
const entries = configuredNavEntries(new Set(["ssh_tasks"]));
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].label).toBe("SSH Tasks");
|
||||
it("does not expose remote machines as a top-level entry", () => {
|
||||
expect(configuredNavEntries(new Set(["remote_machine"]))).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns all observability entries", () => {
|
||||
const entries = configuredNavEntries(
|
||||
new Set(["alertmanager", "prometheus"]),
|
||||
);
|
||||
expect(entries.map((e) => e.label)).toEqual(["Alertmanager", "Prometheus"]);
|
||||
expect(entries.map((entry) => entry.label)).toEqual([
|
||||
"Alertmanager",
|
||||
"Prometheus",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns Backups + Authentik when configured", () => {
|
||||
const entries = configuredNavEntries(new Set(["backups", "authentik"]));
|
||||
expect(entries.map((e) => e.label)).toEqual(["Backups", "Authentik"]);
|
||||
expect(entries.map((entry) => entry.label)).toEqual([
|
||||
"Backups",
|
||||
"Authentik",
|
||||
]);
|
||||
});
|
||||
|
||||
it("nextcloud has no nav entries in the static map", () => {
|
||||
it("keeps non-operational service types out of the static map", () => {
|
||||
expect(
|
||||
SERVICE_TYPE_NAV_ENTRIES.filter((e) => e.serviceType === "nextcloud"),
|
||||
SERVICE_TYPE_NAV_ENTRIES.filter(
|
||||
(entry) =>
|
||||
entry.serviceType === "nextcloud" ||
|
||||
entry.serviceType === "remote_machine",
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves declaration order across mixed types", () => {
|
||||
it("preserves declaration order across mixed navigable types", () => {
|
||||
const entries = configuredNavEntries(
|
||||
new Set(["authentik", "ssh_tasks", "jellyfin"]),
|
||||
new Set(["authentik", "remote_machine", "jellyfin"]),
|
||||
);
|
||||
expect(entries.map((e) => e.label)).toEqual([
|
||||
expect(entries.map((entry) => entry.label)).toEqual([
|
||||
"Jellyfin",
|
||||
"SSH Tasks",
|
||||
"Authentik",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
GanttChartSquare,
|
||||
Magnet,
|
||||
Monitor,
|
||||
Server,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
@@ -37,12 +36,6 @@ export const SERVICE_TYPE_NAV_ENTRIES: NavEntry[] = [
|
||||
icon: Monitor,
|
||||
path: "/services/jellyfin",
|
||||
},
|
||||
{
|
||||
serviceType: "ssh_tasks",
|
||||
label: "SSH Tasks",
|
||||
icon: Server,
|
||||
path: "/services/ssh_tasks",
|
||||
},
|
||||
{
|
||||
serviceType: "alertmanager",
|
||||
label: "Alertmanager",
|
||||
|
||||
@@ -16,7 +16,7 @@ describe("service registry", () => {
|
||||
"nextcloud",
|
||||
"prometheus",
|
||||
"qbittorrent",
|
||||
"ssh_tasks",
|
||||
"remote_machine",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -30,7 +30,7 @@ describe("service registry", () => {
|
||||
expect(SERVICE_REGISTRY.alertmanager.widgets.map((w) => w.kind)).toEqual([
|
||||
"active_alerts",
|
||||
]);
|
||||
expect(SERVICE_REGISTRY.ssh_tasks.widgets.map((w) => w.kind)).toEqual([
|
||||
expect(SERVICE_REGISTRY.remote_machine.widgets.map((w) => w.kind)).toEqual([
|
||||
"task_output",
|
||||
]);
|
||||
expect(SERVICE_REGISTRY.nextcloud.widgets).toEqual([]);
|
||||
|
||||
@@ -310,10 +310,10 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
|
||||
description: "Self-hosted files and collaboration.",
|
||||
widgets: [],
|
||||
},
|
||||
ssh_tasks: {
|
||||
serviceType: "ssh_tasks",
|
||||
name: "SSH task runner",
|
||||
description: "Run reusable saved tasks over SSH and keep run history.",
|
||||
remote_machine: {
|
||||
serviceType: "remote_machine",
|
||||
name: "Remote machine",
|
||||
description: "SSH transport for files and reusable actions.",
|
||||
widgets: [
|
||||
{
|
||||
kind: "task_output",
|
||||
|
||||
@@ -124,14 +124,14 @@ function ServiceConfigFields({
|
||||
<Field
|
||||
key={key}
|
||||
label={
|
||||
type.service_type === "ssh_tasks" && key === "ssh_key_id"
|
||||
type.service_type === "remote_machine" && key === "ssh_key_id"
|
||||
? "SSH key"
|
||||
: key
|
||||
}
|
||||
htmlFor={`cfg-${key}`}
|
||||
helper={schema.description}
|
||||
>
|
||||
{type.service_type === "ssh_tasks" && key === "ssh_key_id" ? (
|
||||
{type.service_type === "remote_machine" && key === "ssh_key_id" ? (
|
||||
<Select
|
||||
value={String(config[key] ?? "") || noSSHKey}
|
||||
onValueChange={(value) =>
|
||||
|
||||
+797
-1631
File diff suppressed because it is too large
Load Diff
@@ -81,11 +81,11 @@ describe("ServicePage tab skeleton", () => {
|
||||
});
|
||||
|
||||
it("does NOT render Media/Requests for non-jellyfin types", () => {
|
||||
const sshInstance = { ...instance, service_type: "ssh_tasks", id: "ssh-1" };
|
||||
const sshInstance = { ...instance, service_type: "remote_machine", id: "ssh-1" };
|
||||
(
|
||||
window as unknown as { __svcInstances: ServiceInstance[] }
|
||||
).__svcInstances = [sshInstance];
|
||||
renderServicePage("/services/ssh_tasks/ssh-1");
|
||||
renderServicePage("/services/remote_machine/ssh-1");
|
||||
expect(screen.getByRole("tab", { name: "Files" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Actions" })).toBeInTheDocument();
|
||||
expect(
|
||||
|
||||
@@ -76,7 +76,7 @@ vi.mock("../../hooks/useServices", () => ({
|
||||
widget_kinds: [],
|
||||
},
|
||||
{
|
||||
service_type: "ssh_tasks",
|
||||
service_type: "remote_machine",
|
||||
name: "SSH task runner",
|
||||
description: "Run saved tasks over SSH",
|
||||
config_schema: {
|
||||
|
||||
@@ -1,125 +1,39 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { Settings } from "../Settings";
|
||||
import type { MonitoringMachine } from "../../types";
|
||||
|
||||
const saveMachineMutate = vi.fn().mockResolvedValue({});
|
||||
const deleteMachineMutate = vi.fn();
|
||||
const testSSHMutate = vi.fn().mockResolvedValue({
|
||||
message: "SSH auth succeeded",
|
||||
known_hosts_updated: true,
|
||||
});
|
||||
|
||||
let machines: MonitoringMachine[] = [];
|
||||
|
||||
vi.mock("../../hooks/useSettings", () => ({
|
||||
useMonitoringSettings: () => ({ data: machines }),
|
||||
useSSHKeys: () => ({ data: [] }),
|
||||
useSaveMonitoringMachine: () => ({
|
||||
mutateAsync: saveMachineMutate,
|
||||
isPending: false,
|
||||
}),
|
||||
useDeleteMonitoringMachine: () => ({ mutate: deleteMachineMutate }),
|
||||
useTestMonitoringMachineSSH: () => ({
|
||||
mutateAsync: testSSHMutate,
|
||||
isPending: false,
|
||||
}),
|
||||
useResetLocalDatabase: () => ({}),
|
||||
useSaveSSHKey: () => ({ mutateAsync: vi.fn() }),
|
||||
useGenerateSSHKey: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
useDeleteSSHKey: () => ({ mutate: vi.fn() }),
|
||||
}));
|
||||
|
||||
function localMachine(
|
||||
overrides: Partial<MonitoringMachine> = {},
|
||||
): MonitoringMachine {
|
||||
return {
|
||||
id: "m1",
|
||||
name: "This machine",
|
||||
mode: "local",
|
||||
enabled: true,
|
||||
services: ["monitoring", "files", "jellyfin"],
|
||||
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,
|
||||
notes: "Primary node",
|
||||
...overrides,
|
||||
} as MonitoringMachine;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
saveMachineMutate.mockClear();
|
||||
deleteMachineMutate.mockClear();
|
||||
testSSHMutate.mockClear();
|
||||
machines = [];
|
||||
});
|
||||
vi.mock("../../hooks/useServices", () => ({
|
||||
useServiceInstances: () => ({ data: [] }),
|
||||
useServiceTypes: () => ({ data: [] }),
|
||||
useSaveServiceInstance: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
useDeleteServiceInstance: () => ({ mutate: vi.fn() }),
|
||||
useTestServiceInstance: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
describe("Settings", () => {
|
||||
it("renders the machine list from the mocked store", () => {
|
||||
machines = [localMachine()];
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Settings />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(screen.getByText("local · Enabled")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("saves a machine via the editor dialog (controlled useState parity)", async () => {
|
||||
machines = [localMachine()];
|
||||
it("uses Services instead of a standalone Machines tab", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Settings />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// The detail-pane "Edit" has visible text "Edit"; the rail hover edit
|
||||
// affordance is icon-only (aria-label "Edit") — disambiguate by text.
|
||||
const detailEdit = screen
|
||||
.getAllByRole("button", { name: "Edit" })
|
||||
.find((button) => button.textContent === "Edit") as HTMLButtonElement;
|
||||
await userEvent.click(detailEdit);
|
||||
|
||||
expect(screen.getByText("Edit machine")).toBeInTheDocument();
|
||||
|
||||
// Rename through the labeled field, then save.
|
||||
const nameInput = screen.getByLabelText("Name");
|
||||
await userEvent.clear(nameInput);
|
||||
await userEvent.type(nameInput, "Worker node");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save machine" }));
|
||||
|
||||
expect(saveMachineMutate).toHaveBeenCalledTimes(1);
|
||||
const saved = saveMachineMutate.mock.calls[0][0];
|
||||
expect(saved.name).toBe("Worker node");
|
||||
expect(saved.mode).toBe("local");
|
||||
});
|
||||
|
||||
it("deletes a machine through the confirm dialog", async () => {
|
||||
machines = [localMachine()];
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Settings />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// Detail-pane "Delete" opens the confirm dialog.
|
||||
await userEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
expect(screen.getByText("Delete machine?")).toBeInTheDocument();
|
||||
|
||||
// Confirm (the confirm dialog's "Delete" is the last one rendered).
|
||||
const deletes = screen.getAllByRole("button", { name: "Delete" });
|
||||
await userEvent.click(deletes[deletes.length - 1]);
|
||||
|
||||
expect(deleteMachineMutate).toHaveBeenCalledTimes(1);
|
||||
expect(deleteMachineMutate).toHaveBeenCalledWith("m1");
|
||||
expect(screen.getByRole("tab", { name: "Services" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "SSH Keys" })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("tab", { name: "Machines" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("No service instances configured yet."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* ActionsTab — operational content for the ssh_tasks service page.
|
||||
* ActionsTab — operational content for the remote_machine service page.
|
||||
*
|
||||
* Lifted from the old top-level `pages/Actions.tsx`. The `instance` prop
|
||||
* provides the active ssh_tasks service id, which is used as the default run
|
||||
* provides the active remote_machine service id, which is used as the default run
|
||||
* service. The page-level header is removed (the service page provides it).
|
||||
* The task editor dialog, saved-task rail, and run history are preserved.
|
||||
*/
|
||||
@@ -10,11 +10,11 @@ import type { ReactNode } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { SavedTask, SavedTaskInput, ServiceInstance } from "../../types";
|
||||
import {
|
||||
useDeleteTask,
|
||||
useRunTask,
|
||||
useSaveTask,
|
||||
useTaskRuns,
|
||||
useTasks,
|
||||
useDeleteTask,
|
||||
useRunTask,
|
||||
useSaveTask,
|
||||
useTaskRuns,
|
||||
useTasks,
|
||||
} from "../../hooks/useSettings";
|
||||
import { DialogFooter } from "../../components/DialogFooter";
|
||||
import { HoverEditButton } from "../../components/HoverEditButton";
|
||||
@@ -25,20 +25,20 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
@@ -47,410 +47,423 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
type ActionTab = "new" | string;
|
||||
|
||||
function FormField({
|
||||
label,
|
||||
htmlFor,
|
||||
helperText,
|
||||
children,
|
||||
label,
|
||||
htmlFor,
|
||||
helperText,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
htmlFor?: string;
|
||||
helperText?: string;
|
||||
children: ReactNode;
|
||||
label: string;
|
||||
htmlFor?: string;
|
||||
helperText?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<Label htmlFor={htmlFor} className="mb-1">
|
||||
{label}
|
||||
</Label>
|
||||
{children}
|
||||
{helperText ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{helperText}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<Label htmlFor={htmlFor} className="mb-1">
|
||||
{label}
|
||||
</Label>
|
||||
{children}
|
||||
{helperText ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{helperText}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function emptyTask(): SavedTaskInput {
|
||||
return {
|
||||
id: null,
|
||||
name: "",
|
||||
task_type: "shell",
|
||||
content: "",
|
||||
enabled: true,
|
||||
default_service_id: "",
|
||||
notes: "",
|
||||
};
|
||||
return {
|
||||
id: null,
|
||||
name: "",
|
||||
task_type: "shell",
|
||||
content: "",
|
||||
enabled: true,
|
||||
service_id: "",
|
||||
notes: "",
|
||||
};
|
||||
}
|
||||
|
||||
function sameTask(a: SavedTaskInput, b: SavedTaskInput) {
|
||||
return (
|
||||
a.id === b.id &&
|
||||
a.name === b.name &&
|
||||
a.task_type === b.task_type &&
|
||||
a.content === b.content &&
|
||||
a.enabled === b.enabled &&
|
||||
a.default_service_id === b.default_service_id &&
|
||||
a.notes === b.notes
|
||||
);
|
||||
return (
|
||||
a.id === b.id &&
|
||||
a.name === b.name &&
|
||||
a.task_type === b.task_type &&
|
||||
a.content === b.content &&
|
||||
a.enabled === b.enabled &&
|
||||
a.service_id === b.service_id &&
|
||||
a.notes === b.notes
|
||||
);
|
||||
}
|
||||
|
||||
function initialFromTask(task: SavedTask): SavedTaskInput {
|
||||
return {
|
||||
id: task.id,
|
||||
name: task.name,
|
||||
task_type: task.task_type,
|
||||
content: task.content,
|
||||
enabled: task.enabled,
|
||||
default_service_id: task.default_service_id,
|
||||
notes: task.notes,
|
||||
};
|
||||
return {
|
||||
id: task.id,
|
||||
name: task.name,
|
||||
task_type: task.task_type,
|
||||
content: task.content,
|
||||
enabled: task.enabled,
|
||||
service_id: task.service_id,
|
||||
notes: task.notes,
|
||||
};
|
||||
}
|
||||
|
||||
function TaskEditor({
|
||||
task,
|
||||
onChange,
|
||||
task,
|
||||
onChange,
|
||||
}: {
|
||||
task: SavedTaskInput;
|
||||
onChange: (task: SavedTaskInput) => void;
|
||||
task: SavedTaskInput;
|
||||
onChange: (task: SavedTaskInput) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-semibold">
|
||||
{task.id ? "Edit action" : "New action"}
|
||||
</p>
|
||||
<Badge variant="outline">{task.task_type}</Badge>
|
||||
<Badge variant="outline">{task.enabled ? "enabled" : "disabled"}</Badge>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<FormField label="Name" htmlFor="task-name">
|
||||
<Input
|
||||
id="task-name"
|
||||
value={task.name}
|
||||
onChange={(e) => onChange({ ...task, name: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex flex-row flex-wrap gap-2">
|
||||
<div className="min-w-[180px] flex-1">
|
||||
<FormField label="Type">
|
||||
<Select
|
||||
value={task.task_type}
|
||||
onValueChange={(value) =>
|
||||
onChange({
|
||||
...task,
|
||||
task_type: value as SavedTaskInput["task_type"],
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="shell">Shell</SelectItem>
|
||||
<SelectItem value="python">Python</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
<FormField label="Notes">
|
||||
<Input
|
||||
value={task.notes}
|
||||
onChange={(e) => onChange({ ...task, notes: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={
|
||||
task.task_type === "python" ? "Python script" : "Shell command"
|
||||
}
|
||||
helperText={
|
||||
task.task_type === "python"
|
||||
? "Python is run as `python3 -c`."
|
||||
: "Shell commands are run through `/bin/sh -c`."
|
||||
}
|
||||
>
|
||||
<Textarea
|
||||
rows={9}
|
||||
value={task.content}
|
||||
onChange={(e) => onChange({ ...task, content: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-semibold">
|
||||
{task.id ? "Edit action" : "New action"}
|
||||
</p>
|
||||
<Badge variant="outline">{task.task_type}</Badge>
|
||||
<Badge variant="outline">{task.enabled ? "enabled" : "disabled"}</Badge>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<FormField label="Name" htmlFor="task-name">
|
||||
<Input
|
||||
id="task-name"
|
||||
value={task.name}
|
||||
onChange={(e) => onChange({ ...task, name: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex flex-row flex-wrap gap-2">
|
||||
<div className="min-w-[180px] flex-1">
|
||||
<FormField label="Type">
|
||||
<Select
|
||||
value={task.task_type}
|
||||
onValueChange={(value) =>
|
||||
onChange({
|
||||
...task,
|
||||
task_type: value as SavedTaskInput["task_type"],
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="shell">Shell</SelectItem>
|
||||
<SelectItem value="python">Python</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
<FormField label="Notes">
|
||||
<Input
|
||||
value={task.notes}
|
||||
onChange={(e) => onChange({ ...task, notes: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={
|
||||
task.task_type === "python" ? "Python script" : "Shell command"
|
||||
}
|
||||
helperText={
|
||||
task.task_type === "python"
|
||||
? "Python is run as `python3 -c`."
|
||||
: "Shell commands are run through `/bin/sh -c`."
|
||||
}
|
||||
>
|
||||
<Textarea
|
||||
rows={9}
|
||||
value={task.content}
|
||||
onChange={(e) => onChange({ ...task, content: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskDialog({
|
||||
open,
|
||||
task,
|
||||
baseline,
|
||||
onClose,
|
||||
onChange,
|
||||
onSave,
|
||||
onDelete,
|
||||
open,
|
||||
task,
|
||||
baseline,
|
||||
onClose,
|
||||
onChange,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: {
|
||||
open: boolean;
|
||||
task: SavedTaskInput;
|
||||
baseline: SavedTaskInput;
|
||||
onClose: () => void;
|
||||
onChange: (task: SavedTaskInput) => void;
|
||||
onSave: () => void;
|
||||
onDelete?: () => void;
|
||||
open: boolean;
|
||||
task: SavedTaskInput;
|
||||
baseline: SavedTaskInput;
|
||||
onClose: () => void;
|
||||
onChange: (task: SavedTaskInput) => void;
|
||||
onSave: () => void;
|
||||
onDelete?: () => void;
|
||||
}) {
|
||||
const requestClose = () => {
|
||||
if (
|
||||
!sameTask(task, baseline) &&
|
||||
!window.confirm("Discard unsaved changes?")
|
||||
)
|
||||
return;
|
||||
onClose();
|
||||
};
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) requestClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{task.id ? "Edit action" : "New action"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Save a reusable server task. Shell commands run via{" "}
|
||||
<code>/bin/sh -c</code>; Python runs via <code>python3 -c</code>.
|
||||
Runs execute on this SSH task service instance.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<TaskEditor task={task} onChange={onChange} />
|
||||
<DialogFooter
|
||||
onCancel={requestClose}
|
||||
cancelLabel="Cancel"
|
||||
onConfirm={onSave}
|
||||
confirmLabel="Save action"
|
||||
confirmBusyLabel="Save action"
|
||||
secondaryAction={
|
||||
onDelete ? (
|
||||
<Button variant="destructive" onClick={onDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
const requestClose = () => {
|
||||
if (
|
||||
!sameTask(task, baseline) &&
|
||||
!window.confirm("Discard unsaved changes?")
|
||||
)
|
||||
return;
|
||||
onClose();
|
||||
};
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) requestClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{task.id ? "Edit action" : "New action"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Save a reusable server task. Shell commands run via{" "}
|
||||
<code>/bin/sh -c</code>; Python runs via <code>python3 -c</code>.
|
||||
Runs execute on this SSH task service instance.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<TaskEditor task={task} onChange={onChange} />
|
||||
<DialogFooter
|
||||
onCancel={requestClose}
|
||||
cancelLabel="Cancel"
|
||||
onConfirm={onSave}
|
||||
confirmLabel="Save action"
|
||||
confirmBusyLabel="Save action"
|
||||
secondaryAction={
|
||||
onDelete ? (
|
||||
<Button variant="destructive" onClick={onDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActionsTab({ instance }: { instance: ServiceInstance }) {
|
||||
const { data: tasks = [] } = useTasks();
|
||||
const saveTask = useSaveTask();
|
||||
const deleteTask = useDeleteTask();
|
||||
const runTask = useRunTask();
|
||||
const [tab, setTab] = useState<ActionTab>("new");
|
||||
const [draft, setDraft] = useState<SavedTaskInput>(emptyTask());
|
||||
const [draftBaseline, setDraftBaseline] = useState<SavedTaskInput>(
|
||||
emptyTask(),
|
||||
);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const { data: tasks = [] } = useTasks(instance.id);
|
||||
const saveTask = useSaveTask();
|
||||
const deleteTask = useDeleteTask();
|
||||
const runTask = useRunTask();
|
||||
const [tab, setTab] = useState<ActionTab>("new");
|
||||
const [draft, setDraft] = useState<SavedTaskInput>(() => ({
|
||||
...emptyTask(),
|
||||
service_id: instance.id,
|
||||
}));
|
||||
const [draftBaseline, setDraftBaseline] =
|
||||
useState<SavedTaskInput>(emptyTask());
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
|
||||
// Default to this instance's service id for task runs.
|
||||
const runServiceId = instance.id;
|
||||
// Default to this instance's service id for task runs.
|
||||
const runServiceId = instance.id;
|
||||
|
||||
const selectedTask = useMemo(
|
||||
() => tasks.find((task) => task.id === tab) ?? null,
|
||||
[tasks, tab],
|
||||
);
|
||||
const selectedRuns = useTaskRuns(selectedTask?.id);
|
||||
const selectedTask = useMemo(
|
||||
() => tasks.find((task) => task.id === tab) ?? null,
|
||||
[tasks, tab],
|
||||
);
|
||||
const selectedRuns = useTaskRuns(selectedTask?.id, instance.id);
|
||||
|
||||
const openEdit = (initial: SavedTaskInput) => {
|
||||
setDraft(initial);
|
||||
setDraftBaseline(initial);
|
||||
setEditOpen(true);
|
||||
};
|
||||
const openEdit = (initial: SavedTaskInput) => {
|
||||
setDraft(initial);
|
||||
setDraftBaseline(initial);
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const saveDraft = async () => {
|
||||
const saved = await saveTask.mutateAsync(draft);
|
||||
setTab(saved.id);
|
||||
setEditOpen(false);
|
||||
const nextDraft = {
|
||||
id: saved.id,
|
||||
name: saved.name,
|
||||
task_type: saved.task_type,
|
||||
content: saved.content,
|
||||
enabled: saved.enabled,
|
||||
default_service_id: saved.default_service_id,
|
||||
notes: saved.notes,
|
||||
};
|
||||
setDraft(nextDraft);
|
||||
setDraftBaseline(nextDraft);
|
||||
};
|
||||
const saveDraft = async () => {
|
||||
const saved = await saveTask.mutateAsync({
|
||||
...draft,
|
||||
service_id: instance.id,
|
||||
});
|
||||
setTab(saved.id);
|
||||
setEditOpen(false);
|
||||
const nextDraft = {
|
||||
id: saved.id,
|
||||
name: saved.name,
|
||||
task_type: saved.task_type,
|
||||
content: saved.content,
|
||||
enabled: saved.enabled,
|
||||
service_id: saved.service_id,
|
||||
notes: saved.notes,
|
||||
};
|
||||
setDraft(nextDraft);
|
||||
setDraftBaseline(nextDraft);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{saveTask.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{String(saveTask.error)}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{deleteTask.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{String(deleteTask.error)}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{runTask.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{String(runTask.error)}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{saveTask.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{String(saveTask.error)}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{deleteTask.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{String(deleteTask.error)}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{runTask.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{String(runTask.error)}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-[280px_minmax(0,1fr)]">
|
||||
<SelectionRailCard
|
||||
title="Saved actions"
|
||||
description="Pick a saved task, then edit or run it from the detail pane."
|
||||
contentSx={{}}
|
||||
footer={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => openEdit(emptyTask())}
|
||||
>
|
||||
Add action
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(value) => setTab(value)}
|
||||
orientation="vertical"
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList variant="line" className="h-fit w-full justify-start">
|
||||
{tasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="group relative w-full group-hover:[&_.rail-edit]:opacity-100"
|
||||
>
|
||||
<TabsTrigger
|
||||
value={task.id}
|
||||
className="w-full justify-start pr-9"
|
||||
onClick={() => setTab(task.id)}
|
||||
onDoubleClick={() => openEdit(initialFromTask(task))}
|
||||
>
|
||||
{task.name}
|
||||
</TabsTrigger>
|
||||
<div className="absolute top-1/2 right-1 -translate-y-1/2">
|
||||
<HoverEditButton
|
||||
onClick={() => openEdit(initialFromTask(task))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</SelectionRailCard>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-[280px_minmax(0,1fr)]">
|
||||
<SelectionRailCard
|
||||
title="Saved actions"
|
||||
description="Pick a saved task, then edit or run it from the detail pane."
|
||||
contentSx={{}}
|
||||
footer={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() =>
|
||||
openEdit({ ...emptyTask(), service_id: instance.id })
|
||||
}
|
||||
>
|
||||
Add action
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(value) => setTab(value)}
|
||||
orientation="vertical"
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList variant="line" className="h-fit w-full justify-start">
|
||||
{tasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="group relative w-full group-hover:[&_.rail-edit]:opacity-100"
|
||||
>
|
||||
<TabsTrigger
|
||||
value={task.id}
|
||||
className="w-full justify-start pr-9"
|
||||
onClick={() => setTab(task.id)}
|
||||
onDoubleClick={() => openEdit(initialFromTask(task))}
|
||||
>
|
||||
{task.name}
|
||||
</TabsTrigger>
|
||||
<div className="absolute top-1/2 right-1 -translate-y-1/2">
|
||||
<HoverEditButton
|
||||
onClick={() => openEdit(initialFromTask(task))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</SelectionRailCard>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{selectedTask ? (
|
||||
<SectionCard
|
||||
title={selectedTask.name}
|
||||
description="Open the editor popup to modify this action."
|
||||
action={
|
||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => openEdit(initialFromTask(selectedTask))}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
disabled={runTask.isPending}
|
||||
onClick={async () => {
|
||||
await runTask.mutateAsync({
|
||||
taskId: selectedTask.id,
|
||||
serviceId: runServiceId,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{runTask.isPending ? "Running..." : "Run action"}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Separator />
|
||||
<p className="text-sm font-semibold">Recent runs</p>
|
||||
{selectedRuns.data?.items?.length ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{selectedRuns.data.items.map((run) => (
|
||||
<Card key={run.id}>
|
||||
<CardContent className="flex flex-col gap-2 p-3">
|
||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">{run.status}</Badge>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(run.created_at * 1000).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
{run.stdout_tail && (
|
||||
<div className="rounded-lg border border-border px-3 py-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
stdout
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap break-words font-mono text-sm">
|
||||
{run.stdout_tail}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{run.stderr_tail && (
|
||||
<div className="rounded-lg border border-border px-3 py-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
stderr
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap break-words font-mono text-sm">
|
||||
{run.stderr_tail}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{run.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{run.error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>No runs yet.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</SectionCard>
|
||||
) : (
|
||||
<SectionCard
|
||||
title="No action selected"
|
||||
description="Select a saved action from the list on the left to view its details, run it, or open the editor popup."
|
||||
>
|
||||
{tasks[0] && (
|
||||
<Button variant="outline" onClick={() => setTab(tasks[0].id)}>
|
||||
Select first action
|
||||
</Button>
|
||||
)}
|
||||
</SectionCard>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
{selectedTask ? (
|
||||
<SectionCard
|
||||
title={selectedTask.name}
|
||||
description="Open the editor popup to modify this action."
|
||||
action={
|
||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => openEdit(initialFromTask(selectedTask))}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
disabled={runTask.isPending}
|
||||
onClick={async () => {
|
||||
await runTask.mutateAsync({
|
||||
taskId: selectedTask.id,
|
||||
serviceId: runServiceId,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{runTask.isPending ? "Running..." : "Run action"}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Separator />
|
||||
<p className="text-sm font-semibold">Recent runs</p>
|
||||
{selectedRuns.data?.items?.length ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{selectedRuns.data.items.map((run) => (
|
||||
<Card key={run.id}>
|
||||
<CardContent className="flex flex-col gap-2 p-3">
|
||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">{run.status}</Badge>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(run.created_at * 1000).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
{run.stdout_tail && (
|
||||
<div className="rounded-lg border border-border px-3 py-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
stdout
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap break-words font-mono text-sm">
|
||||
{run.stdout_tail}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{run.stderr_tail && (
|
||||
<div className="rounded-lg border border-border px-3 py-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
stderr
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap break-words font-mono text-sm">
|
||||
{run.stderr_tail}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{run.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{run.error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>No runs yet.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</SectionCard>
|
||||
) : (
|
||||
<SectionCard
|
||||
title="No action selected"
|
||||
description="Select a saved action from the list on the left to view its details, run it, or open the editor popup."
|
||||
>
|
||||
{tasks[0] && (
|
||||
<Button variant="outline" onClick={() => setTab(tasks[0].id)}>
|
||||
Select first action
|
||||
</Button>
|
||||
)}
|
||||
</SectionCard>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TaskDialog
|
||||
open={editOpen}
|
||||
task={draft}
|
||||
baseline={draftBaseline}
|
||||
onClose={() => setEditOpen(false)}
|
||||
onChange={setDraft}
|
||||
onSave={saveDraft}
|
||||
onDelete={
|
||||
draft.id ? () => deleteTask.mutate(String(draft.id)) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
<TaskDialog
|
||||
open={editOpen}
|
||||
task={draft}
|
||||
baseline={draftBaseline}
|
||||
onClose={() => setEditOpen(false)}
|
||||
onChange={setDraft}
|
||||
onSave={saveDraft}
|
||||
onDelete={
|
||||
draft.id
|
||||
? () =>
|
||||
deleteTask.mutate({
|
||||
taskId: String(draft.id),
|
||||
serviceId: instance.id,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* FilesTab — operational content for the ssh_tasks service page.
|
||||
* FilesTab — operational content for the remote_machine service page.
|
||||
*
|
||||
* Lifted from the old top-level `pages/FileBrowser.impl.tsx`. The machine
|
||||
* selector and `useMonitoringSettings` are removed; the active ssh_tasks
|
||||
* instance id (from the `instance` prop) replaces the machine_id. The initial
|
||||
* selector and `useMonitoringSettings` are removed; the active remote_machine
|
||||
* instance id (from the `instance` prop) replaces the service_id. The initial
|
||||
* path is read from `?path=` search param for deep-link support (resolves the
|
||||
* MediaTab row-click navigation from slice 5). Everything else — directory
|
||||
* listing, path bar, ffprobe preview, job execution — is preserved.
|
||||
|
||||
@@ -202,7 +202,7 @@ function BuildProgress({ value }: { value: number | null }) {
|
||||
|
||||
export function MediaTab({ instance }: { instance: ServiceInstance }) {
|
||||
const navigate = useNavigate();
|
||||
const { data: sshServices = [] } = useServiceInstances("ssh_tasks");
|
||||
const { data: sshServices = [] } = useServiceInstances("remote_machine");
|
||||
const isSmall = usePrefersSmallScreen();
|
||||
const isMobile = useIsMobile();
|
||||
const serviceId = instance.id;
|
||||
@@ -278,13 +278,13 @@ export function MediaTab({ instance }: { instance: ServiceInstance }) {
|
||||
}, [mediaState.columnVisibility, isSmall]);
|
||||
|
||||
const handleRowClick = (row: MediaItem) => {
|
||||
// Navigate to the ssh_tasks service page with the path query param.
|
||||
// If an ssh_tasks instance exists, open its Files tab; otherwise land
|
||||
// on the ssh_tasks type page (empty state / ServiceTypePage resolver).
|
||||
// Navigate to the remote_machine service page with the path query param.
|
||||
// If an remote_machine instance exists, open its Files tab; otherwise land
|
||||
// on the remote_machine type page (empty state / ServiceTypePage resolver).
|
||||
const sshInstance = sshServices.find((s) => s.enabled);
|
||||
const base = sshInstance
|
||||
? `/services/ssh_tasks/${sshInstance.id}`
|
||||
: "/services/ssh_tasks";
|
||||
? `/services/remote_machine/${sshInstance.id}`
|
||||
: "/services/remote_machine";
|
||||
navigate(`${base}?path=${encodeURIComponent(row.path)}`);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,110 +1,29 @@
|
||||
/**
|
||||
* Prometheus Metrics tab (spec R2.4, R8.2).
|
||||
*
|
||||
* Instance-scoped tab showing Prometheus service health.
|
||||
* usePrometheusStatus is scoped by instance.id; usePrometheusTargets
|
||||
* stays global (returns Node Exporter scrape targets for external Prom).
|
||||
*/
|
||||
import { Radio } from "lucide-react";
|
||||
import {
|
||||
usePrometheusStatus,
|
||||
usePrometheusTargets,
|
||||
} from "../../hooks/useObservability";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { usePrometheusStatus } from "../../hooks/useObservability";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { PrometheusTarget, ServiceInstance } from "../../types";
|
||||
|
||||
function TargetsTable({ targets }: { targets: PrometheusTarget[] }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{targets.map((target, idx) => (
|
||||
<div key={idx} className="rounded-lg border p-3">
|
||||
<div className="font-mono text-sm">{target.targets.join(", ")}</div>
|
||||
{target.labels && Object.keys(target.labels).length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{Object.entries(target.labels).map(([key, value]) => (
|
||||
<Badge key={key} variant="outline" className="text-[10px]">
|
||||
{key}: {value}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import type { ServiceInstance } from "../../types";
|
||||
|
||||
export function MetricsTab({ instance }: { instance: ServiceInstance }) {
|
||||
const {
|
||||
data: status,
|
||||
isLoading: statusLoading,
|
||||
error: statusError,
|
||||
} = usePrometheusStatus(instance.id);
|
||||
const {
|
||||
data: targets,
|
||||
isLoading: targetsLoading,
|
||||
error: targetsError,
|
||||
} = usePrometheusTargets();
|
||||
|
||||
const statusDetail = status?.up
|
||||
? status.version
|
||||
? `version ${status.version}`
|
||||
: "reachable"
|
||||
: statusLoading
|
||||
? "checking…"
|
||||
: "unreachable";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Radio className="h-4 w-4" />
|
||||
Prometheus {statusDetail}
|
||||
</div>
|
||||
|
||||
{statusError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Failed to reach Prometheus</AlertTitle>
|
||||
<AlertDescription>{statusError.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Radio className="h-4 w-4" />
|
||||
Node Exporter Targets ({targets?.length ?? 0})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{targetsLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
) : !targets || targets.length === 0 ? (
|
||||
<div className="flex h-full min-h-[160px] flex-col items-center justify-center gap-2 rounded-md border p-6 text-center">
|
||||
<Radio className="h-8 w-8 text-muted-foreground" />
|
||||
<div className="font-medium">No Node Exporter targets</div>
|
||||
<div className="max-w-md text-sm text-muted-foreground">
|
||||
Enable Node Exporter on an SSH machine in Settings to populate
|
||||
Prometheus scrape targets.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<TargetsTable targets={targets} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{targetsError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Failed to load targets</AlertTitle>
|
||||
<AlertDescription>{targetsError.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
const { data: status, isLoading, error } = usePrometheusStatus(instance.id);
|
||||
const detail = status?.up
|
||||
? status.version
|
||||
? `version ${status.version}`
|
||||
: "reachable"
|
||||
: isLoading
|
||||
? "checking…"
|
||||
: "unreachable";
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Radio className="h-4 w-4" />
|
||||
Prometheus {detail}
|
||||
</div>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Failed to reach Prometheus</AlertTitle>
|
||||
<AlertDescription>{error.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { ServiceInstance } from "../../../types";
|
||||
|
||||
const instance: ServiceInstance = {
|
||||
id: "ssh-1",
|
||||
service_type: "ssh_tasks",
|
||||
service_type: "remote_machine",
|
||||
name: "Storage Server",
|
||||
config: {},
|
||||
secrets_set: {},
|
||||
@@ -24,7 +24,7 @@ vi.mock("../../../hooks/useSettings", () => ({
|
||||
task_type: "shell",
|
||||
content: "df -h",
|
||||
enabled: true,
|
||||
default_service_id: "",
|
||||
service_id: "",
|
||||
notes: "",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { ServiceInstance } from "../../../types";
|
||||
|
||||
const instance: ServiceInstance = {
|
||||
id: "ssh-1",
|
||||
service_type: "ssh_tasks",
|
||||
service_type: "remote_machine",
|
||||
name: "Storage Server",
|
||||
config: {},
|
||||
secrets_set: {},
|
||||
@@ -40,7 +40,7 @@ vi.mock("../../../hooks/usePersistentState", () => ({
|
||||
]),
|
||||
}));
|
||||
|
||||
function renderTab(path = "/services/ssh_tasks/ssh-1") {
|
||||
function renderTab(path = "/services/remote_machine/ssh-1") {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<FilesTab instance={instance} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MetricsTab } from "../MetricsTab";
|
||||
import type { ServiceInstance } from "../../../types";
|
||||
@@ -25,27 +25,13 @@ vi.mock("../../../hooks/useObservability", () => ({
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
usePrometheusTargets: () => ({
|
||||
data: [
|
||||
{
|
||||
targets: ["10.0.0.5:9100"],
|
||||
labels: { instance: "storage", job: "node_exporter" },
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("MetricsTab", () => {
|
||||
it("renders the Prometheus version and target list", () => {
|
||||
it("renders the Prometheus version without Manage-owned target discovery", () => {
|
||||
render(<MetricsTab instance={instance} />);
|
||||
expect(screen.getByText(/version 2\.52\.0/)).toBeInTheDocument();
|
||||
expect(screen.getByText("10.0.0.5:9100")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the target count in the heading", () => {
|
||||
render(<MetricsTab instance={instance} />);
|
||||
expect(screen.getByText(/Node Exporter Targets \(1\)/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/version 2\.52\.0/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Node Exporter Targets/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,7 +42,7 @@ export function serviceContentTabs(serviceType: string): ContentTab[] {
|
||||
{ label: "Media", Component: MediaTab },
|
||||
{ label: "Requests", Component: RequestsTab },
|
||||
];
|
||||
case "ssh_tasks":
|
||||
case "remote_machine":
|
||||
return [
|
||||
{ label: "Files", Component: FilesTab },
|
||||
{ label: "Actions", Component: ActionsTab },
|
||||
|
||||
+353
-402
@@ -3,541 +3,492 @@
|
||||
*/
|
||||
|
||||
export interface MediaCounts {
|
||||
movies: number;
|
||||
series: number;
|
||||
episodes: number;
|
||||
movies: number;
|
||||
series: number;
|
||||
episodes: number;
|
||||
}
|
||||
|
||||
export interface LibraryCount {
|
||||
library: string;
|
||||
type: string;
|
||||
movies: number;
|
||||
series: number;
|
||||
episodes: number;
|
||||
total: number;
|
||||
library: string;
|
||||
type: string;
|
||||
movies: number;
|
||||
series: number;
|
||||
episodes: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface UserDirectoryItem {
|
||||
jellyfin_id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
email: string;
|
||||
email_source: string;
|
||||
avatar: string;
|
||||
avatar_source: string;
|
||||
contactable: boolean;
|
||||
source: string;
|
||||
source_summary: string;
|
||||
name_source: string;
|
||||
access_source: string;
|
||||
jellyseerr_user_id: number | null;
|
||||
jellyseerr_username: string;
|
||||
user_type: number | null;
|
||||
user_type_label: string;
|
||||
role: string;
|
||||
permissions: number;
|
||||
permissions_label: string;
|
||||
request_count: number | null;
|
||||
jellyfin_id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
email: string;
|
||||
email_source: string;
|
||||
avatar: string;
|
||||
avatar_source: string;
|
||||
contactable: boolean;
|
||||
source: string;
|
||||
source_summary: string;
|
||||
name_source: string;
|
||||
access_source: string;
|
||||
jellyseerr_user_id: number | null;
|
||||
jellyseerr_username: string;
|
||||
user_type: number | null;
|
||||
user_type_label: string;
|
||||
role: string;
|
||||
permissions: number;
|
||||
permissions_label: string;
|
||||
request_count: number | null;
|
||||
}
|
||||
|
||||
export interface UserDirectoryResponse {
|
||||
items: UserDirectoryItem[];
|
||||
total: number;
|
||||
jellyseerr_configured: boolean;
|
||||
jellyseerr_available: boolean;
|
||||
jellyseerr_error: string;
|
||||
jellyseerr_jellyfin_user_count: number;
|
||||
jellyseerr_user_count: number;
|
||||
enriched_count: number;
|
||||
items: UserDirectoryItem[];
|
||||
total: number;
|
||||
jellyseerr_configured: boolean;
|
||||
jellyseerr_available: boolean;
|
||||
jellyseerr_error: string;
|
||||
jellyseerr_jellyfin_user_count: number;
|
||||
jellyseerr_user_count: number;
|
||||
enriched_count: number;
|
||||
}
|
||||
|
||||
export interface UserMessageResponse {
|
||||
status: string;
|
||||
request_id: string;
|
||||
subject: string;
|
||||
from_address: string;
|
||||
recipient_count: number;
|
||||
attachment_count: number;
|
||||
recipient_labels: string[];
|
||||
skipped: Array<{ jellyfin_id: string; reason: string }>;
|
||||
status: string;
|
||||
request_id: string;
|
||||
subject: string;
|
||||
from_address: string;
|
||||
recipient_count: number;
|
||||
attachment_count: number;
|
||||
recipient_labels: string[];
|
||||
skipped: Array<{ jellyfin_id: string; reason: string }>;
|
||||
}
|
||||
|
||||
export interface UserMessageQueueStatus {
|
||||
state: "idle" | "busy" | "error" | "stopped";
|
||||
worker_running: boolean;
|
||||
stop_requested: boolean;
|
||||
pending_count: number;
|
||||
active_request_id: string | null;
|
||||
last_request_id: string | null;
|
||||
last_result: string | null;
|
||||
last_error: string;
|
||||
last_error_at: number | null;
|
||||
last_success_at: number | null;
|
||||
last_activity_at: number | null;
|
||||
sent_count: number;
|
||||
failed_count: number;
|
||||
state: "idle" | "busy" | "error" | "stopped";
|
||||
worker_running: boolean;
|
||||
stop_requested: boolean;
|
||||
pending_count: number;
|
||||
active_request_id: string | null;
|
||||
last_request_id: string | null;
|
||||
last_result: string | null;
|
||||
last_error: string;
|
||||
last_error_at: number | null;
|
||||
last_success_at: number | null;
|
||||
last_activity_at: number | null;
|
||||
sent_count: number;
|
||||
failed_count: number;
|
||||
}
|
||||
|
||||
export interface NowPlayingSession {
|
||||
user: string;
|
||||
title: string;
|
||||
type: string;
|
||||
state: string;
|
||||
transcoding: string;
|
||||
transcoding_type: string;
|
||||
device: string;
|
||||
session_id: string;
|
||||
user: string;
|
||||
title: string;
|
||||
type: string;
|
||||
state: string;
|
||||
transcoding: string;
|
||||
transcoding_type: string;
|
||||
device: string;
|
||||
session_id: string;
|
||||
}
|
||||
|
||||
export interface SSHKey {
|
||||
id: string;
|
||||
name: string;
|
||||
private_key_set: boolean;
|
||||
passphrase_set: boolean;
|
||||
public_key: string;
|
||||
fingerprint: string;
|
||||
usage_count: number;
|
||||
notes: string;
|
||||
id: string;
|
||||
name: string;
|
||||
private_key_set: boolean;
|
||||
passphrase_set: boolean;
|
||||
public_key: string;
|
||||
fingerprint: string;
|
||||
usage_count: number;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface SSHKeyInput {
|
||||
id?: string | null;
|
||||
name: string;
|
||||
private_key: string;
|
||||
passphrase: string;
|
||||
public_key: string;
|
||||
fingerprint: string;
|
||||
notes: string;
|
||||
id?: string | null;
|
||||
name: string;
|
||||
private_key: string;
|
||||
passphrase: string;
|
||||
public_key: string;
|
||||
fingerprint: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface SSHKeyGenerated {
|
||||
name: string;
|
||||
private_key: string;
|
||||
passphrase: string;
|
||||
notes: string;
|
||||
public_key: string;
|
||||
fingerprint: string;
|
||||
usage_count: number;
|
||||
name: string;
|
||||
private_key: string;
|
||||
passphrase: string;
|
||||
notes: string;
|
||||
public_key: string;
|
||||
fingerprint: string;
|
||||
usage_count: number;
|
||||
}
|
||||
|
||||
export interface SavedTask {
|
||||
id: string;
|
||||
name: string;
|
||||
task_type: "shell" | "python";
|
||||
content: string;
|
||||
enabled: boolean;
|
||||
default_service_id: string;
|
||||
notes: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
id: string;
|
||||
name: string;
|
||||
task_type: "shell" | "python";
|
||||
content: string;
|
||||
enabled: boolean;
|
||||
service_id: string;
|
||||
notes: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface SavedTaskInput {
|
||||
id?: string | null;
|
||||
name: string;
|
||||
task_type: "shell" | "python";
|
||||
content: string;
|
||||
enabled: boolean;
|
||||
default_service_id: string;
|
||||
notes: string;
|
||||
id?: string | null;
|
||||
name: string;
|
||||
task_type: "shell" | "python";
|
||||
content: string;
|
||||
enabled: boolean;
|
||||
service_id: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface SavedTaskRun {
|
||||
id: string;
|
||||
task_id: string;
|
||||
service_id: string;
|
||||
status: "success" | "failure" | "error" | "timeout" | string;
|
||||
exit_status: number | null;
|
||||
created_at: number;
|
||||
duration_ms: number;
|
||||
stdout_tail: string;
|
||||
stderr_tail: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface MonitoringMachine {
|
||||
id: string;
|
||||
name: string;
|
||||
mode: "local" | "ssh";
|
||||
enabled: boolean;
|
||||
services: string[];
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
key_directory: string;
|
||||
key_name: string;
|
||||
ssh_key_id: string;
|
||||
ssh_private_key_set: boolean;
|
||||
ssh_private_key_passphrase_set: boolean;
|
||||
password_set: boolean;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface MonitoringMachineInput {
|
||||
id?: string | null;
|
||||
name: string;
|
||||
mode: "local" | "ssh";
|
||||
enabled: boolean;
|
||||
services: string[];
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
key_directory: string;
|
||||
key_name: string;
|
||||
ssh_key_id: string;
|
||||
ssh_private_key: string;
|
||||
ssh_private_key_passphrase: string;
|
||||
password: string;
|
||||
notes: string;
|
||||
id: string;
|
||||
task_id: string;
|
||||
service_id: string;
|
||||
status: "success" | "failure" | "error" | "timeout" | string;
|
||||
exit_status: number | null;
|
||||
created_at: number;
|
||||
duration_ms: number;
|
||||
stdout_tail: string;
|
||||
stderr_tail: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface ResetLocalDatabaseInput {
|
||||
confirm_phrase: string;
|
||||
acknowledge_settings_loss: boolean;
|
||||
acknowledge_media_index_loss: boolean;
|
||||
acknowledge_irreversible: boolean;
|
||||
confirm_phrase: string;
|
||||
acknowledge_settings_loss: boolean;
|
||||
acknowledge_media_index_loss: boolean;
|
||||
acknowledge_irreversible: boolean;
|
||||
}
|
||||
|
||||
export interface ResetLocalDatabaseResponse {
|
||||
status: string;
|
||||
settings_db_removed: boolean;
|
||||
media_index_removed: boolean;
|
||||
settings_files: string[];
|
||||
media_index_files: string[];
|
||||
}
|
||||
|
||||
export interface SSHValidationResult {
|
||||
status: string;
|
||||
message: string;
|
||||
host: string;
|
||||
port: number;
|
||||
known_hosts_updated: boolean;
|
||||
status: string;
|
||||
settings_db_removed: boolean;
|
||||
media_index_removed: boolean;
|
||||
settings_files: string[];
|
||||
media_index_files: string[];
|
||||
}
|
||||
|
||||
export interface AppVersionInfo {
|
||||
app: string;
|
||||
backend_version: string;
|
||||
backend_build: string;
|
||||
backend_label: string;
|
||||
app: string;
|
||||
backend_version: string;
|
||||
backend_build: string;
|
||||
backend_label: string;
|
||||
}
|
||||
|
||||
export interface MediaIndexStatus {
|
||||
exists: boolean;
|
||||
item_count: number;
|
||||
updated_at: number | null;
|
||||
updated_at_label: string;
|
||||
build_duration_seconds: number | null;
|
||||
build_running: boolean;
|
||||
build_stage: string;
|
||||
build_message: string;
|
||||
build_progress: number | null;
|
||||
build_items_processed: number;
|
||||
build_items_total: number;
|
||||
build_current_library: string;
|
||||
build_library_index: number;
|
||||
build_libraries_total: number;
|
||||
build_library_progress: number | null;
|
||||
build_library_items_processed: number;
|
||||
build_library_items_total: number;
|
||||
build_elapsed_seconds: number | null;
|
||||
build_eta_seconds: number | null;
|
||||
build_library_elapsed_seconds: number | null;
|
||||
build_library_eta_seconds: number | null;
|
||||
build_cancel_requested: boolean;
|
||||
build_pid: number | null;
|
||||
build_error: string;
|
||||
exists: boolean;
|
||||
item_count: number;
|
||||
updated_at: number | null;
|
||||
updated_at_label: string;
|
||||
build_duration_seconds: number | null;
|
||||
build_running: boolean;
|
||||
build_stage: string;
|
||||
build_message: string;
|
||||
build_progress: number | null;
|
||||
build_items_processed: number;
|
||||
build_items_total: number;
|
||||
build_current_library: string;
|
||||
build_library_index: number;
|
||||
build_libraries_total: number;
|
||||
build_library_progress: number | null;
|
||||
build_library_items_processed: number;
|
||||
build_library_items_total: number;
|
||||
build_elapsed_seconds: number | null;
|
||||
build_eta_seconds: number | null;
|
||||
build_library_elapsed_seconds: number | null;
|
||||
build_library_eta_seconds: number | null;
|
||||
build_cancel_requested: boolean;
|
||||
build_pid: number | null;
|
||||
build_error: string;
|
||||
}
|
||||
|
||||
export interface MediaIndexActionResponse {
|
||||
status: string;
|
||||
build_running: boolean;
|
||||
build_stage: string;
|
||||
build_message: string;
|
||||
build_progress: number | null;
|
||||
build_items_processed: number;
|
||||
build_items_total: number;
|
||||
build_current_library: string;
|
||||
build_library_index: number;
|
||||
build_libraries_total: number;
|
||||
build_library_progress: number | null;
|
||||
build_library_items_processed: number;
|
||||
build_library_items_total: number;
|
||||
build_elapsed_seconds: number | null;
|
||||
build_eta_seconds: number | null;
|
||||
build_library_elapsed_seconds: number | null;
|
||||
build_library_eta_seconds: number | null;
|
||||
build_cancel_requested: boolean;
|
||||
build_pid: number | null;
|
||||
build_error: string;
|
||||
status: string;
|
||||
build_running: boolean;
|
||||
build_stage: string;
|
||||
build_message: string;
|
||||
build_progress: number | null;
|
||||
build_items_processed: number;
|
||||
build_items_total: number;
|
||||
build_current_library: string;
|
||||
build_library_index: number;
|
||||
build_libraries_total: number;
|
||||
build_library_progress: number | null;
|
||||
build_library_items_processed: number;
|
||||
build_library_items_total: number;
|
||||
build_elapsed_seconds: number | null;
|
||||
build_eta_seconds: number | null;
|
||||
build_library_elapsed_seconds: number | null;
|
||||
build_library_eta_seconds: number | null;
|
||||
build_cancel_requested: boolean;
|
||||
build_pid: number | null;
|
||||
build_error: string;
|
||||
}
|
||||
|
||||
export interface MediaItem {
|
||||
id: string;
|
||||
title: string;
|
||||
series: string;
|
||||
season: string;
|
||||
episode: number | null;
|
||||
type: string;
|
||||
year: number | null;
|
||||
runtime_min: number | null;
|
||||
size: string;
|
||||
bitrate: string;
|
||||
hdr: string;
|
||||
video: string;
|
||||
resolution: string;
|
||||
date_added: string;
|
||||
library: string;
|
||||
path: string;
|
||||
id: string;
|
||||
title: string;
|
||||
series: string;
|
||||
season: string;
|
||||
episode: number | null;
|
||||
type: string;
|
||||
year: number | null;
|
||||
runtime_min: number | null;
|
||||
size: string;
|
||||
bitrate: string;
|
||||
hdr: string;
|
||||
video: string;
|
||||
resolution: string;
|
||||
date_added: string;
|
||||
library: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface MediaQueryResponse {
|
||||
items: MediaItem[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
items: MediaItem[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface FileEntry {
|
||||
type: string;
|
||||
size: number;
|
||||
mtime: number;
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
mtime: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface DirectoryListing {
|
||||
path: string;
|
||||
entries: FileEntry[];
|
||||
count: number;
|
||||
path: string;
|
||||
entries: FileEntry[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface JobTemplate {
|
||||
key: string;
|
||||
name: string;
|
||||
description: string;
|
||||
key: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface JobResult {
|
||||
job_key: string;
|
||||
path: string;
|
||||
exit_status: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
job_key: string;
|
||||
path: string;
|
||||
exit_status: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export interface ResolvedPath {
|
||||
original: string;
|
||||
resolved: string;
|
||||
original: string;
|
||||
resolved: string;
|
||||
}
|
||||
|
||||
export interface DashboardShortcut {
|
||||
id: string;
|
||||
label: string;
|
||||
shortcut_type: "website" | "action" | "user";
|
||||
enabled: boolean;
|
||||
icon: string;
|
||||
url: string;
|
||||
task_id: string;
|
||||
machine_id: string;
|
||||
user_id: string;
|
||||
notes: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
id: string;
|
||||
label: string;
|
||||
shortcut_type: "website" | "action" | "user";
|
||||
enabled: boolean;
|
||||
icon: string;
|
||||
url: string;
|
||||
task_id: string;
|
||||
machine_id: string;
|
||||
user_id: string;
|
||||
notes: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface DashboardShortcutInput {
|
||||
id?: string | null;
|
||||
label: string;
|
||||
shortcut_type: "website" | "action" | "user";
|
||||
enabled: boolean;
|
||||
icon: string;
|
||||
url: string;
|
||||
task_id: string;
|
||||
machine_id: string;
|
||||
user_id: string;
|
||||
notes: string;
|
||||
id?: string | null;
|
||||
label: string;
|
||||
shortcut_type: "website" | "action" | "user";
|
||||
enabled: boolean;
|
||||
icon: string;
|
||||
url: string;
|
||||
task_id: string;
|
||||
machine_id: string;
|
||||
user_id: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagerAlert {
|
||||
name: string;
|
||||
severity: string;
|
||||
category: string;
|
||||
job_name: string;
|
||||
summary: string;
|
||||
description: string;
|
||||
active_since: string;
|
||||
state: string;
|
||||
labels: Record<string, string>;
|
||||
name: string;
|
||||
severity: string;
|
||||
category: string;
|
||||
job_name: string;
|
||||
summary: string;
|
||||
description: string;
|
||||
active_since: string;
|
||||
state: string;
|
||||
labels: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface AlertmanagerAlertSummary {
|
||||
total: number;
|
||||
by_severity: Record<string, number>;
|
||||
alerts: AlertmanagerAlert[];
|
||||
error?: string;
|
||||
total: number;
|
||||
by_severity: Record<string, number>;
|
||||
alerts: AlertmanagerAlert[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagerStatus {
|
||||
up: boolean;
|
||||
version: string;
|
||||
uptime: string;
|
||||
name: string;
|
||||
peers: string[];
|
||||
service_id?: string;
|
||||
error?: string | null;
|
||||
up: boolean;
|
||||
version: string;
|
||||
uptime: string;
|
||||
name: string;
|
||||
peers: string[];
|
||||
service_id?: string;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface PrometheusStatus {
|
||||
up: boolean;
|
||||
version: string;
|
||||
service_id: string;
|
||||
name: string;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface PrometheusTarget {
|
||||
labels: Record<string, string>;
|
||||
targets: string[];
|
||||
up: boolean;
|
||||
version: string;
|
||||
service_id: string;
|
||||
name: string;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface WidgetInstance {
|
||||
id: string;
|
||||
service_id: string | null;
|
||||
widget_kind: string;
|
||||
title: string;
|
||||
config: Record<string, unknown>;
|
||||
enabled: boolean;
|
||||
sort_order: number;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
id: string;
|
||||
service_id: string | null;
|
||||
widget_kind: string;
|
||||
title: string;
|
||||
config: Record<string, unknown>;
|
||||
enabled: boolean;
|
||||
sort_order: number;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface WidgetInstanceInput {
|
||||
id?: string | null;
|
||||
service_id: string | null;
|
||||
widget_kind: string;
|
||||
title: string;
|
||||
config: Record<string, unknown>;
|
||||
enabled: boolean;
|
||||
sort_order: number;
|
||||
id?: string | null;
|
||||
service_id: string | null;
|
||||
widget_kind: string;
|
||||
title: string;
|
||||
config: Record<string, unknown>;
|
||||
enabled: boolean;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface WidgetDataResponse {
|
||||
widget_id: string;
|
||||
data: Record<string, unknown> | null;
|
||||
error: string | null;
|
||||
fetched_at: number;
|
||||
widget_id: string;
|
||||
data: Record<string, unknown> | null;
|
||||
error: string | null;
|
||||
fetched_at: number;
|
||||
}
|
||||
|
||||
export interface SecretFieldInfo {
|
||||
key: string;
|
||||
label: string;
|
||||
required: boolean;
|
||||
helper?: string | null;
|
||||
key: string;
|
||||
label: string;
|
||||
required: boolean;
|
||||
helper?: string | null;
|
||||
}
|
||||
|
||||
export interface ServiceWidgetKindInfo {
|
||||
kind: string;
|
||||
name: string;
|
||||
description: string;
|
||||
config_schema: Record<string, unknown>;
|
||||
default_config: Record<string, unknown>;
|
||||
refresh_interval_ms: number;
|
||||
kind: string;
|
||||
name: string;
|
||||
description: string;
|
||||
config_schema: Record<string, unknown>;
|
||||
default_config: Record<string, unknown>;
|
||||
refresh_interval_ms: number;
|
||||
}
|
||||
|
||||
export interface ServiceTypeInfo {
|
||||
service_type: string;
|
||||
name: string;
|
||||
description: string;
|
||||
config_schema: Record<string, unknown>;
|
||||
secret_fields: SecretFieldInfo[];
|
||||
widget_kinds: ServiceWidgetKindInfo[];
|
||||
service_type: string;
|
||||
name: string;
|
||||
description: string;
|
||||
config_schema: Record<string, unknown>;
|
||||
secret_fields: SecretFieldInfo[];
|
||||
widget_kinds: ServiceWidgetKindInfo[];
|
||||
}
|
||||
|
||||
export interface ServiceInstance {
|
||||
id: string;
|
||||
service_type: string;
|
||||
name: string;
|
||||
config: Record<string, unknown>;
|
||||
secrets_set: Record<string, boolean>;
|
||||
enabled: boolean;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
id: string;
|
||||
service_type: string;
|
||||
name: string;
|
||||
config: Record<string, unknown>;
|
||||
secrets_set: Record<string, boolean>;
|
||||
enabled: boolean;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface SchedulerStatus {
|
||||
service_id: string;
|
||||
action_key: string;
|
||||
worker_running: boolean;
|
||||
enabled: boolean;
|
||||
running: boolean;
|
||||
poll_interval_seconds: number;
|
||||
sample_retention_seconds: number;
|
||||
sample_max_rows: number;
|
||||
next_run_at: number | null;
|
||||
last_attempt_at: number | null;
|
||||
last_success_at: number | null;
|
||||
last_error: string;
|
||||
consecutive_failures: number;
|
||||
backoff_until: number | null;
|
||||
is_stale: boolean;
|
||||
service_id: string;
|
||||
action_key: string;
|
||||
worker_running: boolean;
|
||||
enabled: boolean;
|
||||
running: boolean;
|
||||
poll_interval_seconds: number;
|
||||
sample_retention_seconds: number;
|
||||
sample_max_rows: number;
|
||||
next_run_at: number | null;
|
||||
last_attempt_at: number | null;
|
||||
last_success_at: number | null;
|
||||
last_error: string;
|
||||
consecutive_failures: number;
|
||||
backoff_until: number | null;
|
||||
is_stale: boolean;
|
||||
}
|
||||
|
||||
export interface SchedulerRun {
|
||||
id: string;
|
||||
service_id: string;
|
||||
action_key: string;
|
||||
trigger: "schedule" | "manual";
|
||||
started_at: number;
|
||||
finished_at: number | null;
|
||||
status: "running" | "success" | "failure" | "cancelled";
|
||||
attempt: number;
|
||||
duration_ms: number | null;
|
||||
error: string;
|
||||
created_at: number;
|
||||
id: string;
|
||||
service_id: string;
|
||||
action_key: string;
|
||||
trigger: "schedule" | "manual";
|
||||
started_at: number;
|
||||
finished_at: number | null;
|
||||
status: "running" | "success" | "failure" | "cancelled";
|
||||
attempt: number;
|
||||
duration_ms: number | null;
|
||||
error: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface SchedulerRunsResponse {
|
||||
items: SchedulerRun[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
items: SchedulerRun[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface SchedulerSamplesResponse {
|
||||
service_id: string;
|
||||
window_seconds: number;
|
||||
samples: Array<{
|
||||
ts: number;
|
||||
dl_speed: number;
|
||||
up_speed: number;
|
||||
}>;
|
||||
service_id: string;
|
||||
window_seconds: number;
|
||||
samples: Array<{
|
||||
ts: number;
|
||||
dl_speed: number;
|
||||
up_speed: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface SchedulerManualRunResponse {
|
||||
run: SchedulerRun;
|
||||
status: SchedulerStatus;
|
||||
run: SchedulerRun;
|
||||
status: SchedulerStatus;
|
||||
}
|
||||
|
||||
export interface ServiceInstanceInput {
|
||||
id?: string | null;
|
||||
service_type: string;
|
||||
name: string;
|
||||
config: Record<string, unknown>;
|
||||
secrets: Record<string, string>;
|
||||
enabled: boolean;
|
||||
id?: string | null;
|
||||
service_type: string;
|
||||
name: string;
|
||||
config: Record<string, unknown>;
|
||||
secrets: Record<string, string>;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface ServiceTestResult {
|
||||
ok: boolean;
|
||||
detail: string;
|
||||
evidence: string | null;
|
||||
ok: boolean;
|
||||
detail: string;
|
||||
evidence: string | null;
|
||||
}
|
||||
|
||||
export interface BuiltinWidgetKindInfo {
|
||||
kind: string;
|
||||
name: string;
|
||||
description: string;
|
||||
config_schema: Record<string, unknown>;
|
||||
default_config: Record<string, unknown>;
|
||||
refresh_interval_ms: number;
|
||||
kind: string;
|
||||
name: string;
|
||||
description: string;
|
||||
config_schema: Record<string, unknown>;
|
||||
default_config: Record<string, unknown>;
|
||||
refresh_interval_ms: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user