Add missing frontend and backend files
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
"""Settings router for persistent machine definitions, app credentials, and data reset."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from io import StringIO
|
||||
from typing import Any
|
||||
|
||||
import paramiko
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from media_library_viewer_api.dependencies import get_settings_store
|
||||
from media_library_viewer_api.services.db_maintenance import remove_sqlite_database
|
||||
from media_library_viewer_api.services.media_index import MediaIndex
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
|
||||
|
||||
class MonitoringMachineInput(BaseModel):
|
||||
"""Payload for creating or updating a machine."""
|
||||
|
||||
id: str | None = None
|
||||
name: str = Field(default="")
|
||||
mode: str = Field(default="local", description="local or ssh")
|
||||
enabled: bool = True
|
||||
services: list[str] = Field(default_factory=list)
|
||||
host: str = ""
|
||||
port: int = 22
|
||||
username: str = ""
|
||||
key_directory: str = ""
|
||||
key_name: str = ""
|
||||
ssh_key_id: str = ""
|
||||
ssh_private_key: str = ""
|
||||
ssh_private_key_passphrase: str = ""
|
||||
password: str = ""
|
||||
media_root: str = ""
|
||||
path_prefix: str = ""
|
||||
jellyfin_url: str = ""
|
||||
jellyfin_user_id: str = ""
|
||||
jellyfin_api_key: str = ""
|
||||
jellyseerr_url: str = ""
|
||||
jellyseerr_api_key: str = ""
|
||||
notes: str = ""
|
||||
|
||||
|
||||
@router.get("/machines")
|
||||
def get_machines(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]:
|
||||
return store.list_machines()
|
||||
|
||||
|
||||
@router.post("/machines", status_code=status.HTTP_201_CREATED)
|
||||
def post_machine(
|
||||
machine: MonitoringMachineInput,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
return store.upsert_machine(machine.model_dump(exclude_none=True), machine.id)
|
||||
|
||||
|
||||
@router.put("/machines/{machine_id}")
|
||||
def put_machine(
|
||||
machine_id: str,
|
||||
machine: MonitoringMachineInput,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
if not store.get_machine(machine_id):
|
||||
raise HTTPException(status_code=404, detail="Machine not found")
|
||||
return store.upsert_machine(machine.model_dump(exclude_none=True), machine_id)
|
||||
|
||||
|
||||
@router.delete("/machines/{machine_id}")
|
||||
def delete_machine(machine_id: str, store: SettingsStore = Depends(get_settings_store)) -> dict[str, str]:
|
||||
if not store.get_machine(machine_id):
|
||||
raise HTTPException(status_code=404, detail="Machine not found")
|
||||
store.delete_machine(machine_id)
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
class SSHKeyInput(BaseModel):
|
||||
id: str | None = None
|
||||
name: str = Field(default="")
|
||||
private_key: str = Field(default="")
|
||||
passphrase: str = Field(default="")
|
||||
notes: str = Field(default="")
|
||||
|
||||
|
||||
class SSHKeyGenerateInput(BaseModel):
|
||||
name: str = Field(default="")
|
||||
passphrase: str = Field(default="")
|
||||
notes: str = Field(default="")
|
||||
bits: int = Field(default=4096, ge=2048, le=8192)
|
||||
|
||||
|
||||
@router.post("/ssh-keys/generate")
|
||||
def generate_ssh_key(
|
||||
payload: SSHKeyGenerateInput,
|
||||
) -> dict[str, Any]:
|
||||
key = paramiko.RSAKey.generate(bits=payload.bits)
|
||||
private_buffer = StringIO()
|
||||
key.write_private_key(private_buffer, password=payload.passphrase or None)
|
||||
private_key = private_buffer.getvalue()
|
||||
public_key = f"{key.get_name()} {key.get_base64()}"
|
||||
return {
|
||||
"name": payload.name,
|
||||
"private_key": private_key,
|
||||
"passphrase": payload.passphrase,
|
||||
"notes": payload.notes,
|
||||
"public_key": public_key,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/ssh-keys")
|
||||
def get_ssh_keys(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]:
|
||||
return store.list_ssh_keys()
|
||||
|
||||
|
||||
@router.post("/ssh-keys", status_code=status.HTTP_201_CREATED)
|
||||
def post_ssh_key(
|
||||
key: SSHKeyInput,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
return store.upsert_ssh_key(key.model_dump(exclude_none=True), key.id)
|
||||
|
||||
|
||||
@router.put("/ssh-keys/{key_id}")
|
||||
def put_ssh_key(
|
||||
key_id: str,
|
||||
key: SSHKeyInput,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
if not store.get_ssh_key(key_id):
|
||||
raise HTTPException(status_code=404, detail="SSH key not found")
|
||||
return store.upsert_ssh_key(key.model_dump(exclude_none=True), key_id)
|
||||
|
||||
|
||||
@router.delete("/ssh-keys/{key_id}")
|
||||
def delete_ssh_key(key_id: str, store: SettingsStore = Depends(get_settings_store)) -> dict[str, str]:
|
||||
if not store.get_ssh_key(key_id):
|
||||
raise HTTPException(status_code=404, detail="SSH key not found")
|
||||
store.delete_ssh_key(key_id)
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
class ResetLocalDatabaseInput(BaseModel):
|
||||
confirm_phrase: str = Field(..., description="Must be RESET LOCAL DATABASE")
|
||||
acknowledge_settings_loss: bool = False
|
||||
acknowledge_media_index_loss: bool = False
|
||||
acknowledge_irreversible: bool = False
|
||||
|
||||
|
||||
@router.post("/reset-local-database")
|
||||
def reset_local_database(
|
||||
payload: ResetLocalDatabaseInput,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
expected = "RESET LOCAL DATABASE"
|
||||
if payload.confirm_phrase.strip().upper() != expected:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Confirmation phrase does not match")
|
||||
if not (payload.acknowledge_settings_loss and payload.acknowledge_media_index_loss and payload.acknowledge_irreversible):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="All confirmation checkboxes must be selected")
|
||||
|
||||
settings_removed = remove_sqlite_database(store.db_path)
|
||||
media_index = MediaIndex()
|
||||
media_removed = remove_sqlite_database(media_index.db_path)
|
||||
|
||||
# Recreate the default local machine immediately so the UI remains usable.
|
||||
store.ensure_defaults()
|
||||
|
||||
return {
|
||||
"status": "reset",
|
||||
"settings_db_removed": bool(settings_removed),
|
||||
"media_index_removed": bool(media_removed),
|
||||
"settings_files": settings_removed,
|
||||
"media_index_files": media_removed,
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Saved server actions/tasks router."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shlex
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from media_library_viewer_api.clients.local import LocalCommandClient
|
||||
from media_library_viewer_api.clients.ssh import RemoteSSHClient
|
||||
from media_library_viewer_api.config import get_settings
|
||||
from media_library_viewer_api.dependencies import get_settings_store
|
||||
from media_library_viewer_api.services.known_hosts import ensure_known_host
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
|
||||
|
||||
|
||||
class TaskInput(BaseModel):
|
||||
id: str | None = None
|
||||
name: str = Field(default="")
|
||||
task_type: str = Field(default="shell", description="shell or python")
|
||||
content: str = Field(default="")
|
||||
enabled: bool = True
|
||||
default_machine_id: str = ""
|
||||
notes: str = ""
|
||||
|
||||
|
||||
class RunTaskRequest(BaseModel):
|
||||
task_id: str
|
||||
|
||||
|
||||
def _machine_label(machine: dict[str, Any] | None) -> str:
|
||||
if not machine:
|
||||
return ""
|
||||
return str(machine.get("name") or machine.get("host") or machine.get("id") or "")
|
||||
|
||||
|
||||
def _resolve_machine_for_task(
|
||||
store: SettingsStore,
|
||||
task: dict[str, Any],
|
||||
machine_id: str | None,
|
||||
) -> dict[str, Any] | None:
|
||||
if machine_id:
|
||||
return store.get_machine_config(machine_id) or store.get_machine(machine_id)
|
||||
default_machine_id = str(task.get("default_machine_id") or "").strip()
|
||||
if default_machine_id:
|
||||
return store.get_machine_config(default_machine_id) or store.get_machine(default_machine_id)
|
||||
machines = [machine for machine in store.list_machines() if machine.get("enabled")]
|
||||
return machines[0] if machines else None
|
||||
|
||||
|
||||
def _client_for_machine(store: SettingsStore, machine: dict[str, Any]):
|
||||
mode = str(machine.get("mode") or "local").lower()
|
||||
if mode == "local":
|
||||
return LocalCommandClient()
|
||||
|
||||
host = str(machine.get("host") or "").strip()
|
||||
username = str(machine.get("username") or "").strip()
|
||||
if not host or not username:
|
||||
raise HTTPException(status_code=400, detail="SSH machine is missing host or username")
|
||||
|
||||
settings = get_settings()
|
||||
ensure_known_host(host, int(machine.get("port") or 22), settings.ssh_known_hosts_file)
|
||||
|
||||
private_key = str(machine.get("ssh_private_key") or "")
|
||||
passphrase = str(machine.get("ssh_private_key_passphrase") or "")
|
||||
ssh_key_id = str(machine.get("ssh_key_id") or "").strip()
|
||||
if ssh_key_id:
|
||||
ssh_key = store.get_ssh_key(ssh_key_id)
|
||||
if ssh_key:
|
||||
private_key = str(ssh_key.get("private_key") or private_key)
|
||||
passphrase = str(ssh_key.get("passphrase") or passphrase)
|
||||
|
||||
key_filename = ""
|
||||
if machine.get("key_directory") and machine.get("key_name"):
|
||||
key_filename = f"{machine.get('key_directory')}/{machine.get('key_name')}"
|
||||
|
||||
return RemoteSSHClient(
|
||||
host=host,
|
||||
username=username,
|
||||
port=int(machine.get("port") or 22),
|
||||
key_filename=key_filename or None,
|
||||
private_key=private_key or None,
|
||||
private_key_passphrase=passphrase or None,
|
||||
password=str(machine.get("password") or "") or None,
|
||||
known_hosts_path=str(settings.ssh_known_hosts_file),
|
||||
)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_tasks(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]:
|
||||
return store.list_tasks()
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
def create_task(task: TaskInput, store: SettingsStore = Depends(get_settings_store)) -> dict[str, Any]:
|
||||
return store.upsert_task(task.model_dump(exclude_none=True), task.id)
|
||||
|
||||
|
||||
@router.put("/{task_id}")
|
||||
def update_task(task_id: str, task: TaskInput, store: SettingsStore = Depends(get_settings_store)) -> dict[str, Any]:
|
||||
if not store.get_task(task_id):
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return store.upsert_task(task.model_dump(exclude_none=True), task_id)
|
||||
|
||||
|
||||
@router.delete("/{task_id}")
|
||||
def delete_task(task_id: str, store: SettingsStore = Depends(get_settings_store)) -> dict[str, str]:
|
||||
if not store.get_task(task_id):
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
store.delete_task(task_id)
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.get("/{task_id}/runs")
|
||||
def list_task_runs(
|
||||
task_id: str,
|
||||
limit: int = Query(default=10, ge=1, le=50),
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
if not store.get_task(task_id):
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
runs = store.list_task_runs(task_id, limit=limit)
|
||||
return {"items": runs, "total": len(runs)}
|
||||
|
||||
|
||||
@router.post("/run")
|
||||
def run_task(
|
||||
request: RunTaskRequest,
|
||||
machine_id: str | None = Query(default=None),
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
task = store.get_task(request.task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
if not task.get("enabled", True):
|
||||
raise HTTPException(status_code=400, detail="Task is disabled")
|
||||
|
||||
machine = _resolve_machine_for_task(store, task, machine_id)
|
||||
if not machine:
|
||||
raise HTTPException(status_code=400, detail="No machine is available for this action")
|
||||
|
||||
task_type = str(task.get("task_type") or "shell").lower()
|
||||
command = str(task.get("content") or "")
|
||||
if task_type == "python":
|
||||
command = f"python3 -c {shlex.quote(command)}"
|
||||
elif task_type != "shell":
|
||||
raise HTTPException(status_code=400, detail=f"Unknown task type: {task_type}")
|
||||
|
||||
client = _client_for_machine(store, machine)
|
||||
start = time.perf_counter()
|
||||
machine_name = _machine_label(machine)
|
||||
try:
|
||||
result = client.run(command, timeout=1200)
|
||||
status_text = "success" if result.exit_status == 0 else "error"
|
||||
store.record_task_run(
|
||||
task,
|
||||
status_text,
|
||||
machine_id=str(machine.get("id") or ""),
|
||||
machine_name=machine_name,
|
||||
task_type=task_type,
|
||||
duration_ms=int((time.perf_counter() - start) * 1000),
|
||||
stdout_tail=result.stdout[-4000:],
|
||||
stderr_tail=result.stderr[-4000:],
|
||||
error="" if result.exit_status == 0 else (result.stderr or result.stdout or "Task failed"),
|
||||
)
|
||||
return {
|
||||
"task_id": task["id"],
|
||||
"task_name": task["name"],
|
||||
"machine_id": str(machine.get("id") or ""),
|
||||
"machine_name": machine_name,
|
||||
"task_type": task_type,
|
||||
"exit_status": result.exit_status,
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
}
|
||||
except Exception as exc:
|
||||
store.record_task_run(
|
||||
task,
|
||||
"error",
|
||||
machine_id=str(machine.get("id") or ""),
|
||||
machine_name=machine_name,
|
||||
task_type=task_type,
|
||||
duration_ms=int((time.perf_counter() - start) * 1000),
|
||||
stdout_tail="",
|
||||
stderr_tail="",
|
||||
error=str(exc),
|
||||
)
|
||||
logger.exception("Task execution failed task_id=%s", task["id"])
|
||||
raise
|
||||
Reference in New Issue
Block a user