feat(tasks): unify saved tasks on ssh_tasks services

- Add shared task_runner.run_saved_task helper used by routers/tasks.py and
  widgets/sources.py SshTaskWidgetSource.
- Saved tasks now target ssh_tasks service instances via default_service_id;
  the legacy default_machine_id and saved_task_runs are removed.
- Actions page lists ssh_tasks services for default and run-time selection.
- Update types, API client, hooks, tests, docs, and changelog.

Backend tests: 222 passed. Frontend lint/build/test: clean (71 passed).
This commit is contained in:
Developer
2026-06-23 13:55:13 +00:00
parent d7ad933b2a
commit 50eb76a10d
14 changed files with 371 additions and 410 deletions
@@ -3,18 +3,15 @@
from __future__ import annotations
import logging
import shlex
import time
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from media_library_viewer_api.clients.local import LocalCommandClient
from media_library_viewer_api.clients.ssh import RemoteSSHClient
from media_library_viewer_api.config import get_settings
from media_library_viewer_api.dependencies import get_settings_store
from media_library_viewer_api.services.settings_store import SettingsStore
from media_library_viewer_api.services.task_runner import run_saved_task
from media_library_viewer_api.widgets.sources import ServiceRecord, build_service_record
logger = logging.getLogger(__name__)
@@ -27,7 +24,7 @@ class TaskInput(BaseModel):
task_type: str = Field(default="shell", description="shell or python")
content: str = Field(default="")
enabled: bool = True
default_machine_id: str = ""
default_service_id: str = ""
notes: str = ""
@@ -35,61 +32,31 @@ class RunTaskRequest(BaseModel):
task_id: str
def _machine_label(machine: dict[str, Any] | None) -> str:
if not machine:
def _service_label(service: dict[str, Any] | None) -> str:
if not service:
return ""
return str(machine.get("name") or machine.get("host") or machine.get("id") or "")
return str(service.get("name") or service.get("id") or "")
def _resolve_machine_for_task(
def _resolve_service_for_task(
store: SettingsStore,
task: dict[str, Any],
machine_id: str | None,
service_id: str | None,
) -> dict[str, Any] | None:
if machine_id:
return store.get_machine_config(machine_id) or store.get_machine(machine_id)
default_machine_id = str(task.get("default_machine_id") or "").strip()
if default_machine_id:
return store.get_machine_config(default_machine_id) or store.get_machine(default_machine_id)
machines = [machine for machine in store.list_machines() if machine.get("enabled")]
return machines[0] if machines else None
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 _client_for_machine(store: SettingsStore, machine: dict[str, Any]):
mode = str(machine.get("mode") or "local").lower()
if mode == "local":
return LocalCommandClient()
def _service_row_to_record(service_row: dict[str, Any]) -> ServiceRecord:
"""Build a ServiceRecord from a raw settings_store service row."""
from media_library_viewer_api.services.settings_store import get_settings_store
host = str(machine.get("host") or "").strip()
username = str(machine.get("username") or "").strip()
if not host or not username:
raise HTTPException(status_code=400, detail="SSH machine is missing host or username")
settings = get_settings()
private_key = str(machine.get("ssh_private_key") or "")
passphrase = str(machine.get("ssh_private_key_passphrase") or "")
ssh_key_id = str(machine.get("ssh_key_id") or "").strip()
if ssh_key_id:
ssh_key = store.get_ssh_key(ssh_key_id)
if ssh_key:
private_key = str(ssh_key.get("private_key") or private_key)
passphrase = str(ssh_key.get("passphrase") or passphrase)
key_filename = ""
if machine.get("key_directory") and machine.get("key_name"):
key_filename = f"{machine.get('key_directory')}/{machine.get('key_name')}"
return RemoteSSHClient(
host=host,
username=username,
port=int(machine.get("port") or 22),
key_filename=key_filename or None,
private_key=private_key or None,
private_key_passphrase=passphrase or None,
password=str(machine.get("password") or "") or None,
known_hosts_path=str(settings.ssh_known_hosts_file),
)
return build_service_record(get_settings_store(), service_row)
@router.get("")
@@ -125,14 +92,14 @@ def list_task_runs(
) -> dict[str, Any]:
if not store.get_task(task_id):
raise HTTPException(status_code=404, detail="Task not found")
runs = store.list_task_runs(task_id, limit=limit)
runs = store.list_service_task_runs(task_id=task_id, limit=limit)
return {"items": runs, "total": len(runs)}
@router.post("/run")
def run_task(
request: RunTaskRequest,
machine_id: str | None = Query(default=None),
service_id: str | None = Query(default=None),
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
task = store.get_task(request.task_id)
@@ -141,68 +108,22 @@ def run_task(
if not task.get("enabled", True):
raise HTTPException(status_code=400, detail="Task is disabled")
machine = _resolve_machine_for_task(store, task, machine_id)
if not machine:
raise HTTPException(status_code=400, detail="No machine is available for this action")
service_row = _resolve_service_for_task(store, task, 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")
task_type = str(task.get("task_type") or "shell").lower()
command = str(task.get("content") or "")
if task_type == "python":
command = f"python3 -c {shlex.quote(command)}"
elif task_type != "shell":
raise HTTPException(status_code=400, detail=f"Unknown task type: {task_type}")
service = _service_row_to_record(service_row)
result = run_saved_task(store, task, service)
client = _client_for_machine(store, machine)
start = time.perf_counter()
machine_name = _machine_label(machine)
try:
result = client.run(command, timeout=1200)
stdout = result.stdout or ""
stderr = result.stderr or ""
status_text = "success" if result.exit_status == 0 else "error"
store.record_task_run(
task,
status_text,
machine_id=str(machine.get("id") or ""),
machine_name=machine_name,
task_type=task_type,
duration_ms=int((time.perf_counter() - start) * 1000),
stdout_tail=stdout[-4000:],
stderr_tail=stderr[-4000:],
error="" if result.exit_status == 0 else (stderr or stdout or "Task failed"),
)
return {
"task_id": task["id"],
"task_name": task["name"],
"machine_id": str(machine.get("id") or ""),
"machine_name": machine_name,
"task_type": task_type,
"exit_status": result.exit_status,
"stdout": stdout,
"stderr": stderr,
}
except Exception as exc:
duration_ms = int((time.perf_counter() - start) * 1000)
error_text = str(exc)
store.record_task_run(
task,
"error",
machine_id=str(machine.get("id") or ""),
machine_name=machine_name,
task_type=task_type,
duration_ms=duration_ms,
stdout_tail="",
stderr_tail=error_text[-4000:],
error=error_text,
)
logger.exception("Task execution failed task_id=%s", task["id"])
return {
"task_id": task["id"],
"task_name": task["name"],
"machine_id": str(machine.get("id") or ""),
"machine_name": machine_name,
"task_type": task_type,
"exit_status": 1,
"stdout": "",
"stderr": error_text,
}
return {
"task_id": task["id"],
"task_name": task["name"],
"service_id": service.id,
"service_name": _service_label(service_row),
"task_type": task.get("task_type", "shell"),
"exit_status": result.exit_status,
"stdout": result.stdout,
"stderr": result.stderr,
}