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:
@@ -32,6 +32,12 @@ All notable changes to Manage. Breaking changes are marked with **BREAKING**.
|
||||
|
||||
### **BREAKING**
|
||||
|
||||
- Saved Actions (server tasks) now target `ssh_tasks` service instances instead
|
||||
of monitoring machines. The `default_machine_id` field on saved tasks was
|
||||
replaced with `default_service_id`; the legacy `saved_task_runs` table was
|
||||
dropped and run history now lives in `service_task_runs`. Re-create SSH task
|
||||
runner services on the Services page and re-link saved actions after
|
||||
upgrading.
|
||||
- **`MANAGE_ENCRYPTION_KEY` is now required** to start the backend. Generate one
|
||||
with:
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ class SettingsStore:
|
||||
task_type TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL,
|
||||
default_machine_id TEXT NOT NULL,
|
||||
default_service_id TEXT NOT NULL,
|
||||
notes TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
@@ -121,28 +121,14 @@ class SettingsStore:
|
||||
"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_saved_tasks_name ON saved_tasks(name)")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS saved_task_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
task_name TEXT NOT NULL,
|
||||
machine_id TEXT NOT NULL,
|
||||
machine_name TEXT NOT NULL,
|
||||
task_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
duration_ms INTEGER NOT NULL,
|
||||
request_id TEXT NOT NULL,
|
||||
stdout_tail TEXT NOT NULL,
|
||||
stderr_tail TEXT NOT NULL,
|
||||
error TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_saved_task_runs_task_time ON saved_task_runs(task_id, created_at DESC)"
|
||||
)
|
||||
# saved_tasks.default_machine_id → default_service_id (saved tasks now
|
||||
# target ssh_tasks service instances). Migrate existing columns.
|
||||
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")
|
||||
# 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")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS dashboard_shortcuts (
|
||||
@@ -714,7 +700,7 @@ class SettingsStore:
|
||||
"task_type": row["task_type"],
|
||||
"content": row["content"],
|
||||
"enabled": bool(row["enabled"]),
|
||||
"default_machine_id": row["default_machine_id"],
|
||||
"default_service_id": row["default_service_id"],
|
||||
"notes": row["notes"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
@@ -731,10 +717,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_machine_id = str(
|
||||
payload.get("default_machine_id")
|
||||
if payload.get("default_machine_id") is not None
|
||||
else (current or {}).get("default_machine_id", "") or ""
|
||||
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 ""
|
||||
).strip()
|
||||
notes = str(
|
||||
payload.get("notes") if payload.get("notes") is not None else (current or {}).get("notes", "") or ""
|
||||
@@ -745,7 +731,7 @@ class SettingsStore:
|
||||
"task_type": task_type,
|
||||
"content": content,
|
||||
"enabled": enabled,
|
||||
"default_machine_id": default_machine_id,
|
||||
"default_service_id": default_service_id,
|
||||
"notes": notes,
|
||||
}
|
||||
|
||||
@@ -773,7 +759,7 @@ class SettingsStore:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO saved_tasks (
|
||||
id, name, task_type, content, enabled, default_machine_id,
|
||||
id, name, task_type, content, enabled, default_service_id,
|
||||
notes, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
@@ -782,7 +768,7 @@ class SettingsStore:
|
||||
task_type = excluded.task_type,
|
||||
content = excluded.content,
|
||||
enabled = excluded.enabled,
|
||||
default_machine_id = excluded.default_machine_id,
|
||||
default_service_id = excluded.default_service_id,
|
||||
notes = excluded.notes,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
@@ -792,7 +778,7 @@ class SettingsStore:
|
||||
task["task_type"],
|
||||
task["content"],
|
||||
1 if task["enabled"] else 0,
|
||||
task["default_machine_id"],
|
||||
task["default_service_id"],
|
||||
task["notes"],
|
||||
created_at,
|
||||
now,
|
||||
@@ -805,58 +791,6 @@ class SettingsStore:
|
||||
with self.connect() as conn:
|
||||
conn.execute("DELETE FROM saved_tasks WHERE id = ?", (task_id,))
|
||||
|
||||
def list_task_runs(self, task_id: str, *, limit: int = 10) -> list[dict[str, Any]]:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM saved_task_runs WHERE task_id = ? ORDER BY created_at DESC LIMIT ?",
|
||||
(task_id, max(1, min(int(limit), 50))),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def record_task_run(
|
||||
self,
|
||||
task: dict[str, Any],
|
||||
status: str,
|
||||
*,
|
||||
machine_id: str,
|
||||
machine_name: str,
|
||||
task_type: str,
|
||||
duration_ms: int,
|
||||
request_id: str = "",
|
||||
stdout_tail: str = "",
|
||||
stderr_tail: str = "",
|
||||
error: str = "",
|
||||
) -> None:
|
||||
self.init_schema()
|
||||
now = int(time.time())
|
||||
with self.connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO saved_task_runs (
|
||||
id, task_id, task_name, machine_id, machine_name, task_type,
|
||||
status, created_at, duration_ms, request_id, stdout_tail,
|
||||
stderr_tail, error
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
uuid.uuid4().hex,
|
||||
str(task.get("id") or ""),
|
||||
str(task.get("name") or ""),
|
||||
machine_id,
|
||||
machine_name,
|
||||
task_type,
|
||||
status,
|
||||
now,
|
||||
duration_ms,
|
||||
request_id,
|
||||
stdout_tail,
|
||||
stderr_tail,
|
||||
error,
|
||||
),
|
||||
)
|
||||
|
||||
def _row_to_shortcut(self, row: sqlite3.Row) -> dict[str, Any]:
|
||||
target = json.loads(row["target_json"] or "{}")
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Shared runner for saved tasks over SSH task services.
|
||||
|
||||
Both the Actions page (``routers/tasks.py``) and the SSH task widget
|
||||
(``widgets/sources.py``) run saved tasks against ``ssh_tasks`` 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.
|
||||
|
||||
There is intentionally no local execution mode — tasks are SSH-only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shlex
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from media_library_viewer_api.clients.ssh import RemoteSSHClient
|
||||
from media_library_viewer_api.config import get_settings
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from media_library_viewer_api.widgets.sources import ServiceRecord
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskRunResult:
|
||||
"""Outcome of a single saved-task run."""
|
||||
|
||||
exit_status: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
duration_ms: int
|
||||
status: str # "success" | "failure" | "error"
|
||||
error: str = ""
|
||||
|
||||
|
||||
def build_ssh_client(store: SettingsStore, service: "ServiceRecord") -> RemoteSSHClient:
|
||||
"""Build an SSH client from an ssh_tasks 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")
|
||||
|
||||
settings = get_settings()
|
||||
private_key = ""
|
||||
key_passphrase = ""
|
||||
ssh_key_id = str(config.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 "")
|
||||
key_passphrase = str(ssh_key.get("passphrase") or "")
|
||||
# Service-level passphrase secret takes precedence.
|
||||
key_passphrase = str(service.secrets.get("passphrase") or "") or key_passphrase
|
||||
|
||||
return RemoteSSHClient(
|
||||
host=host,
|
||||
username=username,
|
||||
port=int(config.get("port") or 22),
|
||||
private_key=private_key or None,
|
||||
private_key_passphrase=key_passphrase or None,
|
||||
known_hosts_path=str(settings.ssh_known_hosts_file),
|
||||
timeout=int(config.get("timeout_seconds") or 30),
|
||||
)
|
||||
|
||||
|
||||
def _render_command(task: dict[str, Any]) -> str:
|
||||
"""Render a saved task into a shell command (shell or python3 -c)."""
|
||||
task_type = str(task.get("task_type") or "shell").lower()
|
||||
command = str(task.get("content") or "")
|
||||
if task_type == "python":
|
||||
return f"python3 -c {shlex.quote(command)}"
|
||||
if task_type == "shell":
|
||||
return command
|
||||
raise ValueError(f"Unknown task type: {task_type}")
|
||||
|
||||
|
||||
def run_saved_task(
|
||||
store: SettingsStore,
|
||||
task: dict[str, Any],
|
||||
service: "ServiceRecord",
|
||||
*,
|
||||
timeout: int | None = None,
|
||||
) -> TaskRunResult:
|
||||
"""Run a saved task on an ssh_tasks 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,
|
||||
error). Raises ``ValueError`` for an unsupported task type or an incomplete
|
||||
service config (propagated from ``build_ssh_client`` / ``_render_command``).
|
||||
"""
|
||||
timeout = int(timeout if timeout is not None else service.config.get("timeout_seconds") or 30)
|
||||
client = build_ssh_client(store, service)
|
||||
command = _render_command(task)
|
||||
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
result = client.run(command, timeout=timeout)
|
||||
except Exception as exc:
|
||||
duration_ms = int((time.perf_counter() - start) * 1000)
|
||||
_record(store, task, service, "error", duration_ms=duration_ms, error=str(exc)[:1000])
|
||||
logger.exception("saved task run failed task_id=%s", task.get("id"))
|
||||
return TaskRunResult(
|
||||
exit_status=1,
|
||||
stdout="",
|
||||
stderr=str(exc),
|
||||
duration_ms=duration_ms,
|
||||
status="error",
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
duration_ms = int((time.perf_counter() - start) * 1000)
|
||||
stdout = result.stdout or ""
|
||||
stderr = result.stderr or ""
|
||||
status = "success" if result.exit_status == 0 else "failure"
|
||||
_record(
|
||||
store,
|
||||
task,
|
||||
service,
|
||||
status,
|
||||
exit_status=result.exit_status,
|
||||
duration_ms=duration_ms,
|
||||
stdout_tail=stdout,
|
||||
stderr_tail=stderr,
|
||||
error="" if result.exit_status == 0 else (stderr or stdout or "Task failed"),
|
||||
)
|
||||
return TaskRunResult(
|
||||
exit_status=result.exit_status,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
duration_ms=duration_ms,
|
||||
status=status,
|
||||
)
|
||||
|
||||
|
||||
def _record(
|
||||
store: SettingsStore,
|
||||
task: dict[str, Any],
|
||||
service: "ServiceRecord",
|
||||
status: str,
|
||||
*,
|
||||
exit_status: int | None = None,
|
||||
duration_ms: int = 0,
|
||||
stdout_tail: str = "",
|
||||
stderr_tail: str = "",
|
||||
error: str = "",
|
||||
) -> None:
|
||||
"""Append a service_task_runs row for a saved-task run."""
|
||||
store.record_service_task_run(
|
||||
{
|
||||
"task_id": str(task.get("id") or ""),
|
||||
"service_id": service.id,
|
||||
"status": status,
|
||||
"exit_status": exit_status,
|
||||
"duration_ms": duration_ms,
|
||||
"stdout_tail": stdout_tail,
|
||||
"stderr_tail": stderr_tail,
|
||||
"error": error,
|
||||
}
|
||||
)
|
||||
@@ -12,21 +12,18 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import shlex
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Protocol
|
||||
|
||||
import requests
|
||||
|
||||
from media_library_viewer_api.clients.jellyfin import JellyfinClient
|
||||
from media_library_viewer_api.clients.ssh import RemoteSSHClient
|
||||
from media_library_viewer_api.config import get_settings
|
||||
from media_library_viewer_api.domain.dashboard import (
|
||||
_map_sessions_to_activity_rows,
|
||||
build_backup_dashboard_summary,
|
||||
)
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore, get_settings_store
|
||||
from media_library_viewer_api.services.task_runner import run_saved_task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -200,51 +197,17 @@ class SshTaskWidgetSource:
|
||||
if not task.get("enabled", True):
|
||||
return {"error": "Task is disabled"}
|
||||
|
||||
client = _build_ssh_client(store, service)
|
||||
timeout = int(service.config.get("timeout_seconds") or 30)
|
||||
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":
|
||||
return {"error": f"Unknown task type: {task_type}"}
|
||||
|
||||
start = time.perf_counter()
|
||||
result = await asyncio.wait_for(
|
||||
asyncio.to_thread(client.run, command, timeout),
|
||||
asyncio.to_thread(run_saved_task, store, task, service),
|
||||
timeout=timeout,
|
||||
)
|
||||
duration_ms = int((time.perf_counter() - start) * 1000)
|
||||
stdout = result.stdout or ""
|
||||
stderr = result.stderr or ""
|
||||
store.record_service_task_run(
|
||||
{
|
||||
"task_id": task_id,
|
||||
"service_id": service.id,
|
||||
"status": "success" if result.exit_status == 0 else "failure",
|
||||
"exit_status": result.exit_status,
|
||||
"duration_ms": duration_ms,
|
||||
"stdout_tail": stdout,
|
||||
"stderr_tail": stderr,
|
||||
"error": "" if result.exit_status == 0 else (stderr or stdout or "Task failed"),
|
||||
}
|
||||
)
|
||||
return {"exit_status": result.exit_status, "stdout": stdout, "stderr": stderr}
|
||||
return {"exit_status": result.exit_status, "stdout": result.stdout, "stderr": result.stderr}
|
||||
except asyncio.TimeoutError:
|
||||
_record_timeout(service, config, timeout)
|
||||
return {"error": "Widget data fetch timed out"}
|
||||
except Exception as exc:
|
||||
logger.exception("ssh_task adapter failed")
|
||||
store = get_settings_store()
|
||||
store.record_service_task_run(
|
||||
{
|
||||
"task_id": str(config.get("task_id") or ""),
|
||||
"service_id": service.id if service else "",
|
||||
"status": "error",
|
||||
"duration_ms": 0,
|
||||
"error": str(exc)[:1000],
|
||||
}
|
||||
)
|
||||
return {"error": f"SSH task failed: {exc}"}
|
||||
|
||||
|
||||
@@ -264,37 +227,6 @@ def _record_timeout(service: ServiceRecord | None, config: dict[str, Any], timeo
|
||||
logger.exception("failed to record ssh task timeout")
|
||||
|
||||
|
||||
def _build_ssh_client(store: SettingsStore, service: ServiceRecord) -> RemoteSSHClient:
|
||||
"""Build an SSH client from an ssh_tasks 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")
|
||||
|
||||
settings = get_settings()
|
||||
private_key = ""
|
||||
key_passphrase = ""
|
||||
ssh_key_id = str(config.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 "")
|
||||
key_passphrase = str(ssh_key.get("passphrase") or "")
|
||||
# Service-level passphrase secret takes precedence.
|
||||
key_passphrase = str(service.secrets.get("passphrase") or "") or key_passphrase
|
||||
|
||||
return RemoteSSHClient(
|
||||
host=host,
|
||||
username=username,
|
||||
port=int(config.get("port") or 22),
|
||||
private_key=private_key or None,
|
||||
private_key_passphrase=key_passphrase or None,
|
||||
known_hosts_path=str(settings.ssh_known_hosts_file),
|
||||
timeout=int(config.get("timeout_seconds") or 30),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -359,7 +359,7 @@ async def test_ssh_task_adapter_records_history_on_run(client):
|
||||
"task_type": "shell",
|
||||
"content": "echo hi",
|
||||
"enabled": True,
|
||||
"default_machine_id": "",
|
||||
"default_service_id": "",
|
||||
}
|
||||
)
|
||||
service = store.upsert_service(
|
||||
@@ -369,6 +369,7 @@ async def test_ssh_task_adapter_records_history_on_run(client):
|
||||
fake_result = SimpleNamespace(exit_status=0, stdout="hi\n", stderr="")
|
||||
fake_client = SimpleNamespace(run=lambda *a, **k: fake_result)
|
||||
|
||||
from media_library_viewer_api.services.task_runner import build_ssh_client
|
||||
from media_library_viewer_api.widgets.sources import SshTaskWidgetSource
|
||||
|
||||
adapter = SshTaskWidgetSource()
|
||||
@@ -377,7 +378,7 @@ async def test_ssh_task_adapter_records_history_on_run(client):
|
||||
)
|
||||
with (
|
||||
patch("media_library_viewer_api.widgets.sources.get_settings_store", return_value=store),
|
||||
patch("media_library_viewer_api.widgets.sources._build_ssh_client", return_value=fake_client),
|
||||
patch("media_library_viewer_api.services.task_runner.build_ssh_client", return_value=fake_client),
|
||||
):
|
||||
result = await adapter.fetch(service_record, "task_output", {"task_id": task["id"]})
|
||||
|
||||
|
||||
@@ -249,8 +249,8 @@ fully removed (web-ui-rework; see decision log 2026-06-17).
|
||||
|
||||
- Provide an Actions tab for predefined server tasks that users can save and run later.
|
||||
- The initial task types should support shell commands and Python scripts, while keeping the design flexible for future task types.
|
||||
- Avoid arbitrary free-form command execution for ad-hoc execution; tasks should be stored records with an explicit name, type, content, enabled flag, default machine, and notes.
|
||||
- Support running tasks against either the local API host or a configured SSH machine using the same machine registry used by Monitoring.
|
||||
- Avoid arbitrary free-form command execution for ad-hoc execution; tasks should be stored records with an explicit name, type, content, enabled flag, default SSH task service, and notes.
|
||||
- Support running tasks against `ssh_tasks` service instances only; local execution on the API host is no longer supported.
|
||||
- Command/script content should be executed through the existing safe process helpers and shell-quoted where applicable.
|
||||
- Future destructive actions should require explicit confirmations or dry-run style safeguards.
|
||||
- Job templates should remain centralized in `jobs.py` for future extension.
|
||||
|
||||
@@ -245,19 +245,19 @@ export const saveTask = (task: SavedTaskInput) =>
|
||||
});
|
||||
export const deleteTask = (taskId: string) =>
|
||||
del<{ status: string }>(`/api/tasks/${encodeURIComponent(taskId)}`);
|
||||
export const runTask = (taskId: string, machineId?: string) =>
|
||||
export const runTask = (taskId: string, serviceId?: string) =>
|
||||
post<{
|
||||
task_id: string;
|
||||
task_name: string;
|
||||
machine_id: string;
|
||||
machine_name: string;
|
||||
service_id: string;
|
||||
service_name: string;
|
||||
task_type: string;
|
||||
exit_status: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}>(
|
||||
machineId
|
||||
? `/api/tasks/run?machine_id=${encodeURIComponent(machineId)}`
|
||||
serviceId
|
||||
? `/api/tasks/run?service_id=${encodeURIComponent(serviceId)}`
|
||||
: "/api/tasks/run",
|
||||
{ task_id: taskId },
|
||||
);
|
||||
|
||||
@@ -113,11 +113,11 @@ export function useRunTask() {
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
taskId,
|
||||
machineId,
|
||||
serviceId,
|
||||
}: {
|
||||
taskId: string;
|
||||
machineId?: string;
|
||||
}) => runTask(taskId, machineId),
|
||||
serviceId?: string;
|
||||
}) => runTask(taskId, serviceId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
},
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { MonitoringMachine, SavedTask, SavedTaskInput } from "../types";
|
||||
import type { SavedTask, SavedTaskInput, ServiceInstance } from "../types";
|
||||
import {
|
||||
useDeleteTask,
|
||||
useMonitoringSettings,
|
||||
useRunTask,
|
||||
useSaveTask,
|
||||
useTaskRuns,
|
||||
useTasks,
|
||||
} from "../hooks/useSettings";
|
||||
import { useServiceInstances } from "../hooks/useServices";
|
||||
import { DialogFooter } from "../components/DialogFooter";
|
||||
import { HoverEditButton } from "../components/HoverEditButton";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
@@ -75,7 +75,7 @@ function emptyTask(): SavedTaskInput {
|
||||
task_type: "shell",
|
||||
content: "",
|
||||
enabled: true,
|
||||
default_machine_id: "",
|
||||
default_service_id: "",
|
||||
notes: "",
|
||||
};
|
||||
}
|
||||
@@ -87,7 +87,7 @@ function sameTask(a: SavedTaskInput, b: SavedTaskInput) {
|
||||
a.task_type === b.task_type &&
|
||||
a.content === b.content &&
|
||||
a.enabled === b.enabled &&
|
||||
a.default_machine_id === b.default_machine_id &&
|
||||
a.default_service_id === b.default_service_id &&
|
||||
a.notes === b.notes
|
||||
);
|
||||
}
|
||||
@@ -99,22 +99,22 @@ function initialFromTask(task: SavedTask): SavedTaskInput {
|
||||
task_type: task.task_type,
|
||||
content: task.content,
|
||||
enabled: task.enabled,
|
||||
default_machine_id: task.default_machine_id,
|
||||
default_service_id: task.default_service_id,
|
||||
notes: task.notes,
|
||||
};
|
||||
}
|
||||
|
||||
function TaskEditor({
|
||||
task,
|
||||
machines,
|
||||
services,
|
||||
onChange,
|
||||
}: {
|
||||
task: SavedTaskInput;
|
||||
machines: MonitoringMachine[];
|
||||
services: ServiceInstance[];
|
||||
onChange: (task: SavedTaskInput) => void;
|
||||
}) {
|
||||
const selectedMachine = machines.find(
|
||||
(machine) => machine.id === task.default_machine_id,
|
||||
const selectedService = services.find(
|
||||
(service) => service.id === task.default_service_id,
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -124,8 +124,8 @@ function TaskEditor({
|
||||
</p>
|
||||
<Badge variant="outline">{task.task_type}</Badge>
|
||||
<Badge variant="outline">{task.enabled ? "enabled" : "disabled"}</Badge>
|
||||
{selectedMachine && (
|
||||
<Badge variant="outline">{`default: ${selectedMachine.name}`}</Badge>
|
||||
{selectedService && (
|
||||
<Badge variant="outline">{`default: ${selectedService.name}`}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -160,13 +160,13 @@ function TaskEditor({
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="min-w-[220px] flex-1">
|
||||
<FormField label="Default machine">
|
||||
<FormField label="Default SSH task service">
|
||||
<Select
|
||||
value={task.default_machine_id || NONE}
|
||||
value={task.default_service_id || NONE}
|
||||
onValueChange={(value) =>
|
||||
onChange({
|
||||
...task,
|
||||
default_machine_id: value === NONE ? "" : value,
|
||||
default_service_id: value === NONE ? "" : value,
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -175,9 +175,9 @@ function TaskEditor({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>None</SelectItem>
|
||||
{machines.map((machine) => (
|
||||
<SelectItem key={machine.id} value={machine.id}>
|
||||
{machine.name}
|
||||
{services.map((service) => (
|
||||
<SelectItem key={service.id} value={service.id}>
|
||||
{service.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -216,7 +216,7 @@ function TaskDialog({
|
||||
open,
|
||||
task,
|
||||
baseline,
|
||||
machines,
|
||||
services,
|
||||
onClose,
|
||||
onChange,
|
||||
onSave,
|
||||
@@ -225,7 +225,7 @@ function TaskDialog({
|
||||
open: boolean;
|
||||
task: SavedTaskInput;
|
||||
baseline: SavedTaskInput;
|
||||
machines: MonitoringMachine[];
|
||||
services: ServiceInstance[];
|
||||
onClose: () => void;
|
||||
onChange: (task: SavedTaskInput) => void;
|
||||
onSave: () => void;
|
||||
@@ -254,9 +254,10 @@ function TaskDialog({
|
||||
<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 the selected SSH task service instance.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<TaskEditor task={task} machines={machines} onChange={onChange} />
|
||||
<TaskEditor task={task} services={services} onChange={onChange} />
|
||||
<DialogFooter
|
||||
onCancel={requestClose}
|
||||
cancelLabel="Cancel"
|
||||
@@ -277,7 +278,7 @@ function TaskDialog({
|
||||
}
|
||||
|
||||
export function Actions() {
|
||||
const { data: machines = [] } = useMonitoringSettings();
|
||||
const { data: sshServices = [] } = useServiceInstances("ssh_tasks");
|
||||
const { data: tasks = [] } = useTasks();
|
||||
const saveTask = useSaveTask();
|
||||
const deleteTask = useDeleteTask();
|
||||
@@ -287,7 +288,7 @@ export function Actions() {
|
||||
const [draftBaseline, setDraftBaseline] = useState<SavedTaskInput>(
|
||||
emptyTask(),
|
||||
);
|
||||
const [runMachineId, setRunMachineId] = useState("");
|
||||
const [runServiceId, setRunServiceId] = useState("");
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
|
||||
const selectedTask = useMemo(
|
||||
@@ -306,7 +307,7 @@ export function Actions() {
|
||||
const initial = emptyTask();
|
||||
setDraft(initial);
|
||||
setDraftBaseline(initial);
|
||||
setRunMachineId(machines[0]?.id || "");
|
||||
setRunServiceId(sshServices[0]?.id || "");
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
@@ -320,7 +321,7 @@ export function Actions() {
|
||||
task_type: saved.task_type,
|
||||
content: saved.content,
|
||||
enabled: saved.enabled,
|
||||
default_machine_id: saved.default_machine_id,
|
||||
default_service_id: saved.default_service_id,
|
||||
notes: saved.notes,
|
||||
};
|
||||
setDraft(nextDraft);
|
||||
@@ -418,11 +419,11 @@ export function Actions() {
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
disabled={runTask.isPending || !runMachineId}
|
||||
disabled={runTask.isPending || !runServiceId}
|
||||
onClick={async () => {
|
||||
await runTask.mutateAsync({
|
||||
taskId: editingTask.id,
|
||||
machineId: runMachineId,
|
||||
serviceId: runServiceId,
|
||||
});
|
||||
}}
|
||||
>
|
||||
@@ -432,18 +433,25 @@ export function Actions() {
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<FormField label="Run on machine">
|
||||
<FormField
|
||||
label="Run on SSH task service"
|
||||
htmlFor="run-service-id"
|
||||
>
|
||||
<Select
|
||||
value={runMachineId}
|
||||
onValueChange={(value) => setRunMachineId(value)}
|
||||
value={runServiceId}
|
||||
onValueChange={(value) => setRunServiceId(value)}
|
||||
>
|
||||
<SelectTrigger className="min-w-[240px]" size="sm">
|
||||
<SelectValue placeholder="Select machine" />
|
||||
<SelectTrigger
|
||||
id="run-service-id"
|
||||
className="min-w-[240px]"
|
||||
size="sm"
|
||||
>
|
||||
<SelectValue placeholder="Select service" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{machines.map((machine) => (
|
||||
<SelectItem key={machine.id} value={machine.id}>
|
||||
{machine.name}
|
||||
{sshServices.map((service) => (
|
||||
<SelectItem key={service.id} value={service.id}>
|
||||
{service.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -462,7 +470,6 @@ export function Actions() {
|
||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">{run.status}</Badge>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{run.machine_name} ·{" "}
|
||||
{new Date(run.created_at * 1000).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
@@ -529,7 +536,7 @@ export function Actions() {
|
||||
open={editOpen}
|
||||
task={draft}
|
||||
baseline={draftBaseline}
|
||||
machines={machines}
|
||||
services={sshServices}
|
||||
onClose={() => setEditOpen(false)}
|
||||
onChange={setDraft}
|
||||
onSave={saveDraft}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Actions } from "../Actions";
|
||||
import type { MonitoringMachine, SavedTask } from "../../types";
|
||||
import type { SavedTask, ServiceInstance } from "../../types";
|
||||
|
||||
const saveTaskMutate = vi.fn().mockResolvedValue({
|
||||
id: "t1",
|
||||
@@ -10,17 +10,16 @@ const saveTaskMutate = vi.fn().mockResolvedValue({
|
||||
task_type: "shell",
|
||||
content: "",
|
||||
enabled: true,
|
||||
default_machine_id: "",
|
||||
default_service_id: "",
|
||||
notes: "",
|
||||
});
|
||||
const deleteTaskMutate = vi.fn();
|
||||
const runTaskMutate = vi.fn().mockResolvedValue({});
|
||||
|
||||
let machines: MonitoringMachine[] = [];
|
||||
let sshServices: ServiceInstance[] = [];
|
||||
let tasks: SavedTask[] = [];
|
||||
|
||||
vi.mock("../../hooks/useSettings", () => ({
|
||||
useMonitoringSettings: () => ({ data: machines }),
|
||||
useTasks: () => ({ data: tasks }),
|
||||
useSaveTask: () => ({ mutateAsync: saveTaskMutate, isPending: false }),
|
||||
useDeleteTask: () => ({ mutate: deleteTaskMutate }),
|
||||
@@ -28,29 +27,22 @@ vi.mock("../../hooks/useSettings", () => ({
|
||||
useTaskRuns: () => ({ data: { items: [] } }),
|
||||
}));
|
||||
|
||||
function machine(
|
||||
overrides: Partial<MonitoringMachine> = {},
|
||||
): MonitoringMachine {
|
||||
vi.mock("../../hooks/useServices", () => ({
|
||||
useServiceInstances: () => ({ data: sshServices }),
|
||||
}));
|
||||
|
||||
function sshService(overrides: Partial<ServiceInstance> = {}): ServiceInstance {
|
||||
return {
|
||||
id: "m1",
|
||||
name: "This machine",
|
||||
mode: "local",
|
||||
id: "s1",
|
||||
service_type: "ssh_tasks",
|
||||
name: "Box",
|
||||
config: { host: "box", username: "u" },
|
||||
secrets_set: {},
|
||||
enabled: true,
|
||||
services: ["monitoring", "files"],
|
||||
host: "",
|
||||
port: 22,
|
||||
username: "",
|
||||
key_directory: "",
|
||||
key_name: "",
|
||||
ssh_key_id: "",
|
||||
ssh_private_key_set: false,
|
||||
ssh_private_key_passphrase_set: false,
|
||||
password_set: false,
|
||||
media_root: "",
|
||||
path_prefix: "",
|
||||
notes: "",
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
...overrides,
|
||||
} as MonitoringMachine;
|
||||
} as ServiceInstance;
|
||||
}
|
||||
|
||||
function task(overrides: Partial<SavedTask> = {}): SavedTask {
|
||||
@@ -60,7 +52,7 @@ function task(overrides: Partial<SavedTask> = {}): SavedTask {
|
||||
task_type: "shell",
|
||||
content: "systemctl restart foo",
|
||||
enabled: true,
|
||||
default_machine_id: "",
|
||||
default_service_id: "",
|
||||
notes: "",
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
@@ -72,7 +64,7 @@ beforeEach(() => {
|
||||
saveTaskMutate.mockClear();
|
||||
deleteTaskMutate.mockClear();
|
||||
runTaskMutate.mockClear();
|
||||
machines = [];
|
||||
sshServices = [];
|
||||
tasks = [];
|
||||
});
|
||||
|
||||
@@ -97,10 +89,11 @@ describe("Actions", () => {
|
||||
const saved = saveTaskMutate.mock.calls[0][0];
|
||||
expect(saved.name).toBe("Restart svc");
|
||||
expect(saved.task_type).toBe("shell");
|
||||
expect(saved.default_service_id).toBe("");
|
||||
});
|
||||
|
||||
it("disables the Run button until a run machine is selected", async () => {
|
||||
machines = [machine()];
|
||||
it("disables the Run button until a run service is selected", async () => {
|
||||
sshServices = [sshService()];
|
||||
tasks = [task()];
|
||||
render(<Actions />);
|
||||
|
||||
@@ -110,4 +103,23 @@ describe("Actions", () => {
|
||||
const runButton = screen.getByRole("button", { name: "Run action" });
|
||||
expect(runButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("runs a task on the selected SSH task service", async () => {
|
||||
sshServices = [sshService()];
|
||||
tasks = [task()];
|
||||
render(<Actions />);
|
||||
|
||||
await userEvent.click(screen.getByRole("tab", { name: "Restart svc" }));
|
||||
await userEvent.click(
|
||||
screen.getByRole("combobox", { name: "Run on SSH task service" }),
|
||||
);
|
||||
await userEvent.click(screen.getByRole("option", { name: "Box" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Run action" }));
|
||||
|
||||
expect(runTaskMutate).toHaveBeenCalledTimes(1);
|
||||
expect(runTaskMutate).toHaveBeenCalledWith({
|
||||
taskId: "t1",
|
||||
serviceId: "s1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,3 +23,26 @@ globalThis.ResizeObserver =
|
||||
// Radix popper also probes `requestAnimationFrame`; jsdom provides it, but some
|
||||
// primitives defer layout reads through rAF that never flush in jsdom. Keep the
|
||||
// default rAF; this guard is intentionally minimal.
|
||||
|
||||
// Radix Select uses pointer capture APIs that jsdom does not implement.
|
||||
// Stub them on HTMLElement so opening/closing selects in tests does not throw.
|
||||
if (typeof window !== "undefined" && window.HTMLElement) {
|
||||
const proto = window.HTMLElement.prototype;
|
||||
if (!proto.hasPointerCapture) {
|
||||
proto.hasPointerCapture = () => false;
|
||||
}
|
||||
if (!proto.setPointerCapture) {
|
||||
proto.setPointerCapture = () => {};
|
||||
}
|
||||
if (!proto.releasePointerCapture) {
|
||||
proto.releasePointerCapture = () => {};
|
||||
}
|
||||
}
|
||||
|
||||
// Radix Select also calls scrollIntoView on items when opening; jsdom lacks it.
|
||||
if (typeof window !== "undefined" && window.Element) {
|
||||
const proto = window.Element.prototype;
|
||||
if (!proto.scrollIntoView) {
|
||||
proto.scrollIntoView = () => {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ export interface SavedTask {
|
||||
task_type: "shell" | "python";
|
||||
content: string;
|
||||
enabled: boolean;
|
||||
default_machine_id: string;
|
||||
default_service_id: string;
|
||||
notes: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
@@ -138,21 +138,18 @@ export interface SavedTaskInput {
|
||||
task_type: "shell" | "python";
|
||||
content: string;
|
||||
enabled: boolean;
|
||||
default_machine_id: string;
|
||||
default_service_id: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface SavedTaskRun {
|
||||
id: string;
|
||||
task_id: string;
|
||||
task_name: string;
|
||||
machine_id: string;
|
||||
machine_name: string;
|
||||
task_type: "shell" | "python";
|
||||
status: string;
|
||||
service_id: string;
|
||||
status: "success" | "failure" | "error" | "timeout" | string;
|
||||
exit_status: number | null;
|
||||
created_at: number;
|
||||
duration_ms: number;
|
||||
request_id: string;
|
||||
stdout_tail: string;
|
||||
stderr_tail: string;
|
||||
error: string;
|
||||
@@ -198,44 +195,6 @@ export interface MonitoringMachineInput {
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface SavedTask {
|
||||
id: string;
|
||||
name: string;
|
||||
task_type: "shell" | "python";
|
||||
content: string;
|
||||
enabled: boolean;
|
||||
default_machine_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_machine_id: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface SavedTaskRun {
|
||||
id: string;
|
||||
task_id: string;
|
||||
task_name: string;
|
||||
machine_id: string;
|
||||
machine_name: string;
|
||||
task_type: "shell" | "python";
|
||||
status: string;
|
||||
created_at: number;
|
||||
duration_ms: number;
|
||||
request_id: string;
|
||||
stdout_tail: string;
|
||||
stderr_tail: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface ResetLocalDatabaseInput {
|
||||
confirm_phrase: string;
|
||||
acknowledge_settings_loss: boolean;
|
||||
|
||||
@@ -16,47 +16,47 @@
|
||||
|
||||
**Goal:** One execution path; tasks target ssh_tasks services; one history table.
|
||||
|
||||
- [ ] **1.1 Add shared `run_saved_task` helper**
|
||||
- [x] **1.1 Add shared `run_saved_task` helper**
|
||||
- Files: `backend/src/media_library_viewer_api/services/task_runner.py` (new)
|
||||
- Lines: ~90
|
||||
- Details: `run_saved_task(store, task, service, *, request_id)` builds the SSH
|
||||
client from the service record (promote `_build_ssh_client`), renders the
|
||||
command, runs with the service timeout, appends a `service_task_runs` row,
|
||||
returns a `TaskRunResult`.
|
||||
- [ ] **1.2 Rename saved_tasks column**
|
||||
- [x] **1.2 Rename saved_tasks column**
|
||||
- Files: `services/settings_store.py` (modify)
|
||||
- Lines: ~20
|
||||
- Details: `default_machine_id` → `default_service_id` (ALTER TABLE RENAME
|
||||
COLUMN on startup; update `_row_to_task`, `_normalize_task_payload`,
|
||||
`upsert_task`).
|
||||
- [ ] **1.3 Drop saved_task_runs**
|
||||
- [x] **1.3 Drop saved_task_runs**
|
||||
- Files: `services/settings_store.py` (modify)
|
||||
- Lines: ~-60
|
||||
- Details: `DROP TABLE IF EXISTS saved_task_runs`; remove `record_task_run`
|
||||
and `list_task_runs` (task flavor).
|
||||
- [ ] **1.4 Rewire tasks router**
|
||||
- [x] **1.4 Rewire tasks router**
|
||||
- Files: `routers/tasks.py` (modify)
|
||||
- Lines: ~70
|
||||
- Details: `TaskInput.default_service_id`; `run_task` takes `service_id`
|
||||
(override), resolves an ssh_tasks service, calls `run_saved_task`;
|
||||
`/api/tasks/{id}/runs` reads `service_task_runs`. Remove
|
||||
`_resolve_machine_for_task` and `_client_for_machine`.
|
||||
- [ ] **1.5 Widget delegates to shared helper**
|
||||
- [x] **1.5 Widget delegates to shared helper**
|
||||
- Files: `widgets/sources.py` (modify)
|
||||
- Lines: ~-40
|
||||
- Details: `SshTaskWidgetSource.fetch` calls `run_saved_task` instead of its
|
||||
inline run+log block.
|
||||
- [ ] **1.6 Add `list_service_task_runs` by task (if not present)**
|
||||
- [x] **1.6 Add `list_service_task_runs` by task (if not present)**
|
||||
- Files: `services/settings_store.py` (modify)
|
||||
- Lines: ~10
|
||||
- Details: Confirm `list_service_task_runs(task_id=...)` covers the tasks
|
||||
router needs.
|
||||
- [ ] **1.7 Update backend tests**
|
||||
- [x] **1.7 Update backend tests**
|
||||
- Files: `backend/tests/test_jobs.py`, `test_api.py` (modify)
|
||||
- Lines: ~60
|
||||
- Details: Update task-run tests to the service model; cover override +
|
||||
default + disabled-service paths.
|
||||
- [ ] **1.8 Verify**
|
||||
- [x] **1.8 Verify**
|
||||
- Run: `cd backend && .venv/bin/ruff check . && PYTHONPATH=src .venv/bin/python -m pytest`
|
||||
|
||||
**Slice 1 total:** ~250 changed lines.
|
||||
@@ -65,39 +65,39 @@
|
||||
|
||||
**Goal:** Actions page targets ssh_tasks services; reads service_task_runs.
|
||||
|
||||
- [ ] **2.1 Update types**
|
||||
- [x] **2.1 Update types**
|
||||
- Files: `frontend/src/types/index.ts` (modify)
|
||||
- Lines: ~15
|
||||
- Details: `SavedTask` / `SavedTaskInput` `default_service_id`;
|
||||
`SavedTaskRun` aligned to `service_task_runs`.
|
||||
- [ ] **2.2 Update API client**
|
||||
- [x] **2.2 Update API client**
|
||||
- Files: `frontend/src/api/client.ts` (modify)
|
||||
- Lines: ~10
|
||||
- Details: `runTask(taskId, serviceId?)` sends `service_id`.
|
||||
- [ ] **2.3 Rewire Actions page**
|
||||
- [x] **2.3 Rewire Actions page**
|
||||
- Files: `frontend/src/pages/Actions.tsx` (modify)
|
||||
- Lines: ~120
|
||||
- Details: Task editor "Default service" select lists ssh_tasks services via
|
||||
`useServiceInstances("ssh_tasks")`; run dialog "Run on" selects an instance;
|
||||
run history reads `service_task_runs`. Remove `useMonitoringSettings`.
|
||||
- [ ] **2.4 Update Actions tests**
|
||||
- [x] **2.4 Update Actions tests**
|
||||
- Files: `frontend/src/pages/__tests__/Actions.test.tsx` (modify)
|
||||
- Lines: ~30
|
||||
- Details: Mock `useServiceInstances`; update fixtures.
|
||||
- [ ] **2.5 Docs + changelog**
|
||||
- [x] **2.5 Docs + changelog**
|
||||
- Files: `docs/REQUIREMENTS.md`, `CHANGELOG.md` (modify)
|
||||
- Lines: ~30
|
||||
- Details: Saved-actions section: tasks target ssh_tasks services; local mode
|
||||
dropped; breaking-upgrade note.
|
||||
- [ ] **2.6 Verify**
|
||||
- [x] **2.6 Verify**
|
||||
- Run: `cd frontend && npm run lint && npm run build && npm run test`
|
||||
|
||||
**Slice 2 total:** ~200 changed lines.
|
||||
|
||||
## Integration and acceptance
|
||||
|
||||
- [ ] **3.1 Backend full test run** — `PYTHONPATH=src pytest`, all green.
|
||||
- [ ] **3.2 Frontend full build/lint/test**.
|
||||
- [x] **3.1 Backend full test run** — `PYTHONPATH=src pytest`, all green.
|
||||
- [x] **3.2 Frontend full build/lint/test**.
|
||||
- [ ] **3.3 Manual dev-stack check**:
|
||||
- Create an ssh_tasks service; create a task with that default; run from
|
||||
Actions; see the run in both the Actions history and the service page.
|
||||
|
||||
Reference in New Issue
Block a user