Add missing frontend and backend files
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
"""Local command execution helpers.
|
||||
|
||||
These mirror the remote SSH helpers but execute commands on the API host
|
||||
itself. They are used for the built-in local monitoring machine.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import posixpath
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandResult:
|
||||
"""Plain result object returned by local command execution."""
|
||||
|
||||
command: str
|
||||
exit_status: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
|
||||
|
||||
class LocalCommandClient:
|
||||
"""Execute the same POSIX shell snippets used by the SSH client locally."""
|
||||
|
||||
def __init__(self, timeout: int = 20):
|
||||
self.timeout = timeout
|
||||
|
||||
def run(self, command: str, timeout: int | None = None) -> CommandResult:
|
||||
shell_command = ["/bin/sh", "-c", command]
|
||||
logger.debug("Local run timeout=%s command=%s", timeout or self.timeout, command)
|
||||
proc = subprocess.run(
|
||||
shell_command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout or self.timeout,
|
||||
)
|
||||
result = CommandResult(
|
||||
command=command,
|
||||
exit_status=proc.returncode,
|
||||
stdout=proc.stdout,
|
||||
stderr=proc.stderr,
|
||||
)
|
||||
if result.exit_status == 0:
|
||||
logger.debug("Local command ok exit_status=%s", result.exit_status)
|
||||
else:
|
||||
logger.warning(
|
||||
"Local command failed exit_status=%s stderr=%s",
|
||||
result.exit_status,
|
||||
result.stderr.strip() or result.stdout.strip(),
|
||||
)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def join(parent: str, child: str) -> str:
|
||||
return posixpath.normpath(posixpath.join(parent, child))
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Settings router for persistent machine definitions, app credentials, and data reset."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from io import StringIO
|
||||
from typing import Any
|
||||
|
||||
import paramiko
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from media_library_viewer_api.dependencies import get_settings_store
|
||||
from media_library_viewer_api.services.db_maintenance import remove_sqlite_database
|
||||
from media_library_viewer_api.services.media_index import MediaIndex
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
|
||||
|
||||
class MonitoringMachineInput(BaseModel):
|
||||
"""Payload for creating or updating a machine."""
|
||||
|
||||
id: str | None = None
|
||||
name: str = Field(default="")
|
||||
mode: str = Field(default="local", description="local or ssh")
|
||||
enabled: bool = True
|
||||
services: list[str] = Field(default_factory=list)
|
||||
host: str = ""
|
||||
port: int = 22
|
||||
username: str = ""
|
||||
key_directory: str = ""
|
||||
key_name: str = ""
|
||||
ssh_key_id: str = ""
|
||||
ssh_private_key: str = ""
|
||||
ssh_private_key_passphrase: str = ""
|
||||
password: str = ""
|
||||
media_root: str = ""
|
||||
path_prefix: str = ""
|
||||
jellyfin_url: str = ""
|
||||
jellyfin_user_id: str = ""
|
||||
jellyfin_api_key: str = ""
|
||||
jellyseerr_url: str = ""
|
||||
jellyseerr_api_key: str = ""
|
||||
notes: str = ""
|
||||
|
||||
|
||||
@router.get("/machines")
|
||||
def get_machines(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]:
|
||||
return store.list_machines()
|
||||
|
||||
|
||||
@router.post("/machines", status_code=status.HTTP_201_CREATED)
|
||||
def post_machine(
|
||||
machine: MonitoringMachineInput,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
return store.upsert_machine(machine.model_dump(exclude_none=True), machine.id)
|
||||
|
||||
|
||||
@router.put("/machines/{machine_id}")
|
||||
def put_machine(
|
||||
machine_id: str,
|
||||
machine: MonitoringMachineInput,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
if not store.get_machine(machine_id):
|
||||
raise HTTPException(status_code=404, detail="Machine not found")
|
||||
return store.upsert_machine(machine.model_dump(exclude_none=True), machine_id)
|
||||
|
||||
|
||||
@router.delete("/machines/{machine_id}")
|
||||
def delete_machine(machine_id: str, store: SettingsStore = Depends(get_settings_store)) -> dict[str, str]:
|
||||
if not store.get_machine(machine_id):
|
||||
raise HTTPException(status_code=404, detail="Machine not found")
|
||||
store.delete_machine(machine_id)
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
class SSHKeyInput(BaseModel):
|
||||
id: str | None = None
|
||||
name: str = Field(default="")
|
||||
private_key: str = Field(default="")
|
||||
passphrase: str = Field(default="")
|
||||
notes: str = Field(default="")
|
||||
|
||||
|
||||
class SSHKeyGenerateInput(BaseModel):
|
||||
name: str = Field(default="")
|
||||
passphrase: str = Field(default="")
|
||||
notes: str = Field(default="")
|
||||
bits: int = Field(default=4096, ge=2048, le=8192)
|
||||
|
||||
|
||||
@router.post("/ssh-keys/generate")
|
||||
def generate_ssh_key(
|
||||
payload: SSHKeyGenerateInput,
|
||||
) -> dict[str, Any]:
|
||||
key = paramiko.RSAKey.generate(bits=payload.bits)
|
||||
private_buffer = StringIO()
|
||||
key.write_private_key(private_buffer, password=payload.passphrase or None)
|
||||
private_key = private_buffer.getvalue()
|
||||
public_key = f"{key.get_name()} {key.get_base64()}"
|
||||
return {
|
||||
"name": payload.name,
|
||||
"private_key": private_key,
|
||||
"passphrase": payload.passphrase,
|
||||
"notes": payload.notes,
|
||||
"public_key": public_key,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/ssh-keys")
|
||||
def get_ssh_keys(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]:
|
||||
return store.list_ssh_keys()
|
||||
|
||||
|
||||
@router.post("/ssh-keys", status_code=status.HTTP_201_CREATED)
|
||||
def post_ssh_key(
|
||||
key: SSHKeyInput,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
return store.upsert_ssh_key(key.model_dump(exclude_none=True), key.id)
|
||||
|
||||
|
||||
@router.put("/ssh-keys/{key_id}")
|
||||
def put_ssh_key(
|
||||
key_id: str,
|
||||
key: SSHKeyInput,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
if not store.get_ssh_key(key_id):
|
||||
raise HTTPException(status_code=404, detail="SSH key not found")
|
||||
return store.upsert_ssh_key(key.model_dump(exclude_none=True), key_id)
|
||||
|
||||
|
||||
@router.delete("/ssh-keys/{key_id}")
|
||||
def delete_ssh_key(key_id: str, store: SettingsStore = Depends(get_settings_store)) -> dict[str, str]:
|
||||
if not store.get_ssh_key(key_id):
|
||||
raise HTTPException(status_code=404, detail="SSH key not found")
|
||||
store.delete_ssh_key(key_id)
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
class ResetLocalDatabaseInput(BaseModel):
|
||||
confirm_phrase: str = Field(..., description="Must be RESET LOCAL DATABASE")
|
||||
acknowledge_settings_loss: bool = False
|
||||
acknowledge_media_index_loss: bool = False
|
||||
acknowledge_irreversible: bool = False
|
||||
|
||||
|
||||
@router.post("/reset-local-database")
|
||||
def reset_local_database(
|
||||
payload: ResetLocalDatabaseInput,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
expected = "RESET LOCAL DATABASE"
|
||||
if payload.confirm_phrase.strip().upper() != expected:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Confirmation phrase does not match")
|
||||
if not (payload.acknowledge_settings_loss and payload.acknowledge_media_index_loss and payload.acknowledge_irreversible):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="All confirmation checkboxes must be selected")
|
||||
|
||||
settings_removed = remove_sqlite_database(store.db_path)
|
||||
media_index = MediaIndex()
|
||||
media_removed = remove_sqlite_database(media_index.db_path)
|
||||
|
||||
# Recreate the default local machine immediately so the UI remains usable.
|
||||
store.ensure_defaults()
|
||||
|
||||
return {
|
||||
"status": "reset",
|
||||
"settings_db_removed": bool(settings_removed),
|
||||
"media_index_removed": bool(media_removed),
|
||||
"settings_files": settings_removed,
|
||||
"media_index_files": media_removed,
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Saved server actions/tasks router."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shlex
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from media_library_viewer_api.clients.local import LocalCommandClient
|
||||
from media_library_viewer_api.clients.ssh import RemoteSSHClient
|
||||
from media_library_viewer_api.config import get_settings
|
||||
from media_library_viewer_api.dependencies import get_settings_store
|
||||
from media_library_viewer_api.services.known_hosts import ensure_known_host
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
|
||||
|
||||
|
||||
class TaskInput(BaseModel):
|
||||
id: str | None = None
|
||||
name: str = Field(default="")
|
||||
task_type: str = Field(default="shell", description="shell or python")
|
||||
content: str = Field(default="")
|
||||
enabled: bool = True
|
||||
default_machine_id: str = ""
|
||||
notes: str = ""
|
||||
|
||||
|
||||
class RunTaskRequest(BaseModel):
|
||||
task_id: str
|
||||
|
||||
|
||||
def _machine_label(machine: dict[str, Any] | None) -> str:
|
||||
if not machine:
|
||||
return ""
|
||||
return str(machine.get("name") or machine.get("host") or machine.get("id") or "")
|
||||
|
||||
|
||||
def _resolve_machine_for_task(
|
||||
store: SettingsStore,
|
||||
task: dict[str, Any],
|
||||
machine_id: str | None,
|
||||
) -> dict[str, Any] | None:
|
||||
if machine_id:
|
||||
return store.get_machine_config(machine_id) or store.get_machine(machine_id)
|
||||
default_machine_id = str(task.get("default_machine_id") or "").strip()
|
||||
if default_machine_id:
|
||||
return store.get_machine_config(default_machine_id) or store.get_machine(default_machine_id)
|
||||
machines = [machine for machine in store.list_machines() if machine.get("enabled")]
|
||||
return machines[0] if machines else None
|
||||
|
||||
|
||||
def _client_for_machine(store: SettingsStore, machine: dict[str, Any]):
|
||||
mode = str(machine.get("mode") or "local").lower()
|
||||
if mode == "local":
|
||||
return LocalCommandClient()
|
||||
|
||||
host = str(machine.get("host") or "").strip()
|
||||
username = str(machine.get("username") or "").strip()
|
||||
if not host or not username:
|
||||
raise HTTPException(status_code=400, detail="SSH machine is missing host or username")
|
||||
|
||||
settings = get_settings()
|
||||
ensure_known_host(host, int(machine.get("port") or 22), settings.ssh_known_hosts_file)
|
||||
|
||||
private_key = str(machine.get("ssh_private_key") or "")
|
||||
passphrase = str(machine.get("ssh_private_key_passphrase") or "")
|
||||
ssh_key_id = str(machine.get("ssh_key_id") or "").strip()
|
||||
if ssh_key_id:
|
||||
ssh_key = store.get_ssh_key(ssh_key_id)
|
||||
if ssh_key:
|
||||
private_key = str(ssh_key.get("private_key") or private_key)
|
||||
passphrase = str(ssh_key.get("passphrase") or passphrase)
|
||||
|
||||
key_filename = ""
|
||||
if machine.get("key_directory") and machine.get("key_name"):
|
||||
key_filename = f"{machine.get('key_directory')}/{machine.get('key_name')}"
|
||||
|
||||
return RemoteSSHClient(
|
||||
host=host,
|
||||
username=username,
|
||||
port=int(machine.get("port") or 22),
|
||||
key_filename=key_filename or None,
|
||||
private_key=private_key or None,
|
||||
private_key_passphrase=passphrase or None,
|
||||
password=str(machine.get("password") or "") or None,
|
||||
known_hosts_path=str(settings.ssh_known_hosts_file),
|
||||
)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_tasks(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]:
|
||||
return store.list_tasks()
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
def create_task(task: TaskInput, store: SettingsStore = Depends(get_settings_store)) -> dict[str, Any]:
|
||||
return store.upsert_task(task.model_dump(exclude_none=True), task.id)
|
||||
|
||||
|
||||
@router.put("/{task_id}")
|
||||
def update_task(task_id: str, task: TaskInput, store: SettingsStore = Depends(get_settings_store)) -> dict[str, Any]:
|
||||
if not store.get_task(task_id):
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return store.upsert_task(task.model_dump(exclude_none=True), task_id)
|
||||
|
||||
|
||||
@router.delete("/{task_id}")
|
||||
def delete_task(task_id: str, store: SettingsStore = Depends(get_settings_store)) -> dict[str, str]:
|
||||
if not store.get_task(task_id):
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
store.delete_task(task_id)
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.get("/{task_id}/runs")
|
||||
def list_task_runs(
|
||||
task_id: str,
|
||||
limit: int = Query(default=10, ge=1, le=50),
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
if not store.get_task(task_id):
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
runs = store.list_task_runs(task_id, limit=limit)
|
||||
return {"items": runs, "total": len(runs)}
|
||||
|
||||
|
||||
@router.post("/run")
|
||||
def run_task(
|
||||
request: RunTaskRequest,
|
||||
machine_id: str | None = Query(default=None),
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
task = store.get_task(request.task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
if not task.get("enabled", True):
|
||||
raise HTTPException(status_code=400, detail="Task is disabled")
|
||||
|
||||
machine = _resolve_machine_for_task(store, task, machine_id)
|
||||
if not machine:
|
||||
raise HTTPException(status_code=400, detail="No machine is available for this action")
|
||||
|
||||
task_type = str(task.get("task_type") or "shell").lower()
|
||||
command = str(task.get("content") or "")
|
||||
if task_type == "python":
|
||||
command = f"python3 -c {shlex.quote(command)}"
|
||||
elif task_type != "shell":
|
||||
raise HTTPException(status_code=400, detail=f"Unknown task type: {task_type}")
|
||||
|
||||
client = _client_for_machine(store, machine)
|
||||
start = time.perf_counter()
|
||||
machine_name = _machine_label(machine)
|
||||
try:
|
||||
result = client.run(command, timeout=1200)
|
||||
status_text = "success" if result.exit_status == 0 else "error"
|
||||
store.record_task_run(
|
||||
task,
|
||||
status_text,
|
||||
machine_id=str(machine.get("id") or ""),
|
||||
machine_name=machine_name,
|
||||
task_type=task_type,
|
||||
duration_ms=int((time.perf_counter() - start) * 1000),
|
||||
stdout_tail=result.stdout[-4000:],
|
||||
stderr_tail=result.stderr[-4000:],
|
||||
error="" if result.exit_status == 0 else (result.stderr or result.stdout or "Task failed"),
|
||||
)
|
||||
return {
|
||||
"task_id": task["id"],
|
||||
"task_name": task["name"],
|
||||
"machine_id": str(machine.get("id") or ""),
|
||||
"machine_name": machine_name,
|
||||
"task_type": task_type,
|
||||
"exit_status": result.exit_status,
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
}
|
||||
except Exception as exc:
|
||||
store.record_task_run(
|
||||
task,
|
||||
"error",
|
||||
machine_id=str(machine.get("id") or ""),
|
||||
machine_name=machine_name,
|
||||
task_type=task_type,
|
||||
duration_ms=int((time.perf_counter() - start) * 1000),
|
||||
stdout_tail="",
|
||||
stderr_tail="",
|
||||
error=str(exc),
|
||||
)
|
||||
logger.exception("Task execution failed task_id=%s", task["id"])
|
||||
raise
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Helpers for removing local SQLite databases safely."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def sqlite_sidecar_paths(db_path: Path) -> list[Path]:
|
||||
"""Return the SQLite database file and its WAL/SHM sidecars."""
|
||||
db_path = Path(db_path)
|
||||
return [
|
||||
db_path,
|
||||
db_path.with_name(db_path.name + "-wal"),
|
||||
db_path.with_name(db_path.name + "-shm"),
|
||||
]
|
||||
|
||||
|
||||
def remove_sqlite_database(db_path: Path) -> list[str]:
|
||||
"""Delete the SQLite database file plus WAL/SHM sidecars if present."""
|
||||
removed: list[str] = []
|
||||
for path in sqlite_sidecar_paths(db_path):
|
||||
try:
|
||||
path.unlink()
|
||||
removed.append(str(path))
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
return removed
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Managed known_hosts synthesis for SSH clients.
|
||||
|
||||
Instead of mounting a host-side ``known_hosts`` file, the backend can discover
|
||||
and persist host keys for configured SSH machines inside its own cache volume.
|
||||
This keeps strict host-key checking enabled without exposing a whole SSH
|
||||
configuration directory into the container.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import socket
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import paramiko
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _host_alias(host: str, port: int) -> str:
|
||||
return host if int(port or 22) == 22 else f"[{host}]:{int(port or 22)}"
|
||||
|
||||
|
||||
def _fetch_server_key(host: str, port: int, timeout: int = 10) -> paramiko.PKey:
|
||||
sock = socket.create_connection((host, int(port or 22)), timeout=timeout)
|
||||
transport = paramiko.Transport(sock)
|
||||
try:
|
||||
transport.start_client(timeout=timeout)
|
||||
key = transport.get_remote_server_key()
|
||||
if key is None:
|
||||
raise RuntimeError(f"Unable to read SSH host key for {host}:{port}")
|
||||
return key
|
||||
finally:
|
||||
transport.close()
|
||||
sock.close()
|
||||
|
||||
|
||||
def ensure_known_host(host: str, port: int, known_hosts_path: Path, *, strict: bool = True) -> bool:
|
||||
"""Ensure a host key entry exists for the given host/port.
|
||||
|
||||
Returns ``True`` when the file was changed. If ``strict`` is enabled and the
|
||||
existing key differs, a ``RuntimeError`` is raised instead of silently
|
||||
overwriting the entry.
|
||||
"""
|
||||
if not host:
|
||||
return False
|
||||
known_hosts_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
host_key = _fetch_server_key(host, port)
|
||||
host_alias = _host_alias(host, port)
|
||||
host_keys = paramiko.HostKeys()
|
||||
if known_hosts_path.exists():
|
||||
host_keys.load(str(known_hosts_path))
|
||||
|
||||
existing = host_keys.lookup(host_alias)
|
||||
key_type = host_key.get_name()
|
||||
if existing and key_type in existing:
|
||||
if existing[key_type].get_base64() == host_key.get_base64():
|
||||
return False
|
||||
if strict:
|
||||
raise RuntimeError(f"SSH host key mismatch for {host_alias}")
|
||||
|
||||
host_keys.add(host_alias, key_type, host_key)
|
||||
host_keys.save(str(known_hosts_path))
|
||||
logger.info("Recorded SSH host key host=%s port=%s file=%s", host, port, known_hosts_path)
|
||||
return True
|
||||
|
||||
|
||||
def ensure_known_hosts_for_machines(
|
||||
machines: list[dict[str, Any]],
|
||||
known_hosts_path: Path,
|
||||
*,
|
||||
strict: bool = True,
|
||||
) -> int:
|
||||
changed = 0
|
||||
for machine in machines:
|
||||
if str(machine.get("mode") or "").lower() != "ssh":
|
||||
continue
|
||||
host = str(machine.get("host") or "").strip()
|
||||
port = int(machine.get("port") or 22)
|
||||
if not host:
|
||||
continue
|
||||
if ensure_known_host(host, port, known_hosts_path, strict=strict):
|
||||
changed += 1
|
||||
return changed
|
||||
@@ -0,0 +1,330 @@
|
||||
"""Shared monitoring action helpers.
|
||||
|
||||
The router and the background poller both use these helpers so machine
|
||||
operations are recorded consistently whether they were triggered by a user
|
||||
request or by the backend's scheduled polling loop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Callable
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from media_library_viewer_api.clients.local import LocalCommandClient
|
||||
from media_library_viewer_api.clients.resources import (
|
||||
disk_space,
|
||||
read_resource_metrics,
|
||||
resource_collector_debug_info,
|
||||
resource_collector_status,
|
||||
restart_resource_collector,
|
||||
start_resource_collector,
|
||||
stop_resource_collector,
|
||||
)
|
||||
from media_library_viewer_api.clients.ssh import RemoteSSHClient
|
||||
from media_library_viewer_api.config import get_settings
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def build_machine_client(machine: dict[str, Any]):
|
||||
"""Build the appropriate command client for a machine definition."""
|
||||
mode = str(machine.get("mode") or "local").strip().lower()
|
||||
if mode == "local":
|
||||
return LocalCommandClient()
|
||||
|
||||
key_directory = str(machine.get("key_directory") or "").strip()
|
||||
key_name = str(machine.get("key_name") or "").strip()
|
||||
key_path = f"{key_directory}/{key_name}" if key_directory and key_name else None
|
||||
return RemoteSSHClient(
|
||||
host=str(machine.get("host") or ""),
|
||||
username=str(machine.get("username") or ""),
|
||||
port=int(machine.get("port") or 22),
|
||||
key_filename=key_path,
|
||||
password=str(machine.get("password") or "") or None,
|
||||
)
|
||||
|
||||
|
||||
def summarize_operation_result(action: str, result: Any) -> str:
|
||||
"""Turn an operation result into a compact human-readable summary."""
|
||||
if result is None:
|
||||
return action
|
||||
if isinstance(result, str):
|
||||
text = result.strip().splitlines()[0] if result.strip() else action
|
||||
return text[:200]
|
||||
if isinstance(result, list):
|
||||
return f"{action}: {len(result)} item(s)"
|
||||
if isinstance(result, dict):
|
||||
if action == "metrics read":
|
||||
samples = result.get("samples")
|
||||
if isinstance(samples, list):
|
||||
return f"{action}: {len(samples)} sample(s)"
|
||||
if action.startswith("disk lookup"):
|
||||
used_pct = result.get("used_pct")
|
||||
mount = result.get("mount") or result.get("filesystem")
|
||||
return f"disk {mount or ''} used {used_pct or '?'}".strip()
|
||||
if "message" in result and isinstance(result["message"], str):
|
||||
return result["message"][:200]
|
||||
return json_compact(result)
|
||||
return action
|
||||
|
||||
|
||||
def json_compact(value: Any) -> str:
|
||||
try:
|
||||
text = json.dumps(value, sort_keys=True, default=str)
|
||||
return text[:200]
|
||||
except Exception:
|
||||
return str(value)[:200]
|
||||
|
||||
|
||||
def run_machine_operation(
|
||||
machine: dict[str, Any],
|
||||
store: SettingsStore,
|
||||
action: str,
|
||||
callback: Callable[[Any], Any],
|
||||
*,
|
||||
summarize: Callable[[Any], str] | None = None,
|
||||
request_id: str = "",
|
||||
raise_http: bool = True,
|
||||
) -> Any:
|
||||
"""Run a machine operation, record history, and optionally raise on failure."""
|
||||
started = time.perf_counter()
|
||||
client = build_machine_client(machine)
|
||||
try:
|
||||
result = callback(client)
|
||||
duration_ms = int((time.perf_counter() - started) * 1000)
|
||||
store.record_machine_action(
|
||||
machine,
|
||||
action,
|
||||
"ok",
|
||||
duration_ms=duration_ms,
|
||||
request_id=request_id,
|
||||
message=(summarize(result) if summarize else summarize_operation_result(action, result)),
|
||||
)
|
||||
return result
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc: # pragma: no cover - transport/network fallback
|
||||
duration_ms = int((time.perf_counter() - started) * 1000)
|
||||
logger.exception(
|
||||
"Monitoring %s failed machine_id=%s machine_name=%s",
|
||||
action,
|
||||
machine["id"],
|
||||
machine["name"],
|
||||
)
|
||||
error_text = str(exc)
|
||||
store.record_machine_action(
|
||||
machine,
|
||||
action,
|
||||
"error",
|
||||
duration_ms=duration_ms,
|
||||
request_id=request_id,
|
||||
error=error_text,
|
||||
)
|
||||
if not raise_http:
|
||||
return None
|
||||
status_code = 503 if machine.get("mode") == "local" else 502
|
||||
raise HTTPException(
|
||||
status_code=status_code,
|
||||
detail=f"{machine['name']}: {action} failed: {exc}",
|
||||
) from exc
|
||||
|
||||
|
||||
def poll_machine_snapshot(
|
||||
machine: dict[str, Any],
|
||||
store: SettingsStore,
|
||||
*,
|
||||
metrics_limit: int = 70_000,
|
||||
request_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Collect a backend-scheduled snapshot for a machine.
|
||||
|
||||
This records the same action-history rows that the UI would otherwise get
|
||||
from manual requests, but it runs entirely inside the backend on a schedule.
|
||||
"""
|
||||
request_id = request_id or f"poll:{machine.get('id') or uuid.uuid4().hex}"
|
||||
results: dict[str, Any] = {"request_id": request_id, "machine_id": machine.get("id"), "actions": []}
|
||||
|
||||
status = run_machine_operation(
|
||||
machine,
|
||||
store,
|
||||
"status lookup",
|
||||
resource_collector_status,
|
||||
request_id=request_id,
|
||||
raise_http=False,
|
||||
)
|
||||
results["status"] = status
|
||||
results["actions"].append("status lookup")
|
||||
|
||||
metrics = run_machine_operation(
|
||||
machine,
|
||||
store,
|
||||
"metrics read",
|
||||
lambda client: read_resource_metrics(client, max_lines=metrics_limit),
|
||||
request_id=request_id,
|
||||
raise_http=False,
|
||||
)
|
||||
results["metrics_samples"] = len(metrics or [])
|
||||
results["actions"].append("metrics read")
|
||||
|
||||
settings = get_settings()
|
||||
path = str(machine.get("media_root") or settings.media_root or "/")
|
||||
disk = run_machine_operation(
|
||||
machine,
|
||||
store,
|
||||
f"disk lookup for {path}",
|
||||
lambda client: disk_space(client, path),
|
||||
request_id=request_id,
|
||||
raise_http=False,
|
||||
)
|
||||
results["disk_mount"] = (disk or {}).get("mount") if isinstance(disk, dict) else None
|
||||
results["actions"].append("disk lookup")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _summarize_metric_samples(samples: list[dict[str, Any]], field: str) -> dict[str, float] | None:
|
||||
values = [float(sample.get(field, 0)) for sample in samples if sample.get(field) is not None]
|
||||
if not values:
|
||||
return None
|
||||
return {
|
||||
"avg": sum(values) / len(values),
|
||||
"min": min(values),
|
||||
"max": max(values),
|
||||
"count": float(len(values)),
|
||||
}
|
||||
|
||||
|
||||
def collect_machine_overview(machine: dict[str, Any], *, metrics_window_seconds: int = 600, metrics_limit: int = 70_000) -> dict[str, Any]:
|
||||
"""Collect a lightweight machine overview without recording history."""
|
||||
overview: dict[str, Any] = {
|
||||
"machine": {k: machine.get(k) for k in ("id", "name", "mode", "enabled", "host", "port", "username", "media_root", "path_prefix", "notes")},
|
||||
"status": "",
|
||||
"status_error": "",
|
||||
"metrics_error": "",
|
||||
"disk_error": "",
|
||||
"sample_count": 0,
|
||||
"latest_sample": None,
|
||||
"cpu_summary": None,
|
||||
"iowait_summary": None,
|
||||
"mem_summary": None,
|
||||
"net_rx_summary": None,
|
||||
"net_tx_summary": None,
|
||||
"disk_read_summary": None,
|
||||
"disk_write_summary": None,
|
||||
"disk": None,
|
||||
}
|
||||
try:
|
||||
client: Any = build_machine_client(machine)
|
||||
except Exception as exc:
|
||||
overview["status_error"] = str(exc)
|
||||
overview["metrics_error"] = str(exc)
|
||||
overview["disk_error"] = str(exc)
|
||||
return overview
|
||||
|
||||
settings = get_settings()
|
||||
path = str(machine.get("media_root") or settings.media_root or "/")
|
||||
|
||||
try:
|
||||
overview["status"] = resource_collector_status(client)
|
||||
except Exception as exc:
|
||||
overview["status_error"] = str(exc)
|
||||
|
||||
try:
|
||||
samples = read_resource_metrics(client, max_lines=metrics_limit)
|
||||
if samples:
|
||||
latest_ts = max(float(sample.get("ts", 0)) for sample in samples)
|
||||
window_start = latest_ts - metrics_window_seconds
|
||||
metrics = [sample for sample in samples if float(sample.get("ts", 0)) >= window_start]
|
||||
if not metrics:
|
||||
metrics = samples
|
||||
overview["sample_count"] = len(metrics)
|
||||
overview["latest_sample"] = metrics[-1]
|
||||
overview["cpu_summary"] = _summarize_metric_samples(metrics, "cpu_pct")
|
||||
overview["iowait_summary"] = _summarize_metric_samples(metrics, "iowait_pct")
|
||||
overview["mem_summary"] = _summarize_metric_samples(metrics, "mem_pct")
|
||||
overview["net_rx_summary"] = _summarize_metric_samples(metrics, "net_rx_bytes_per_sec")
|
||||
overview["net_tx_summary"] = _summarize_metric_samples(metrics, "net_tx_bytes_per_sec")
|
||||
overview["disk_read_summary"] = _summarize_metric_samples(metrics, "disk_read_bps")
|
||||
overview["disk_write_summary"] = _summarize_metric_samples(metrics, "disk_write_bps")
|
||||
except Exception as exc:
|
||||
overview["metrics_error"] = str(exc)
|
||||
|
||||
try:
|
||||
overview["disk"] = disk_space(client, path)
|
||||
except Exception as exc:
|
||||
overview["disk_error"] = str(exc)
|
||||
|
||||
return overview
|
||||
|
||||
|
||||
def poll_machine_diagnostics(
|
||||
machine: dict[str, Any],
|
||||
store: SettingsStore,
|
||||
*,
|
||||
request_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Collect a backend-scheduled diagnostics snapshot for a machine."""
|
||||
request_id = request_id or f"poll:{machine.get('id') or uuid.uuid4().hex}"
|
||||
diagnostics = run_machine_operation(
|
||||
machine,
|
||||
store,
|
||||
"collector diagnostics",
|
||||
resource_collector_debug_info,
|
||||
request_id=request_id,
|
||||
raise_http=False,
|
||||
)
|
||||
return {"request_id": request_id, "diagnostics": diagnostics}
|
||||
|
||||
|
||||
def start_collector(
|
||||
machine: dict[str, Any],
|
||||
store: SettingsStore,
|
||||
*,
|
||||
request_id: str = "",
|
||||
) -> Any:
|
||||
return run_machine_operation(
|
||||
machine,
|
||||
store,
|
||||
"collector start",
|
||||
start_resource_collector,
|
||||
request_id=request_id,
|
||||
raise_http=True,
|
||||
)
|
||||
|
||||
|
||||
def stop_collector(
|
||||
machine: dict[str, Any],
|
||||
store: SettingsStore,
|
||||
*,
|
||||
request_id: str = "",
|
||||
) -> Any:
|
||||
return run_machine_operation(
|
||||
machine,
|
||||
store,
|
||||
"collector stop",
|
||||
stop_resource_collector,
|
||||
request_id=request_id,
|
||||
raise_http=True,
|
||||
)
|
||||
|
||||
|
||||
def restart_collector(
|
||||
machine: dict[str, Any],
|
||||
store: SettingsStore,
|
||||
*,
|
||||
request_id: str = "",
|
||||
) -> Any:
|
||||
return run_machine_operation(
|
||||
machine,
|
||||
store,
|
||||
"collector restart",
|
||||
restart_resource_collector,
|
||||
request_id=request_id,
|
||||
raise_http=True,
|
||||
)
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Background poller for monitoring machine snapshots.
|
||||
|
||||
The poller runs entirely inside the backend. It periodically reads the defined
|
||||
machines, collects a small snapshot from each enabled machine over SSH or local
|
||||
shell execution, and stores the resulting history rows in the settings DB.
|
||||
|
||||
This keeps the Monitoring page populated without any daemon or agent running on
|
||||
the remote machines.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from media_library_viewer_api.config import get_settings
|
||||
from media_library_viewer_api.services.monitoring_actions import poll_machine_snapshot
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore, get_settings_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PollerConfig:
|
||||
interval_seconds: int = 300
|
||||
initial_delay_seconds: int = 20
|
||||
metrics_limit: int = 70_000
|
||||
retention_days: int = 30
|
||||
|
||||
|
||||
class MonitoringPoller:
|
||||
"""Single-worker background poller for monitoring snapshots."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._thread: threading.Thread | None = None
|
||||
self._stop_event = threading.Event()
|
||||
self._lock = threading.Lock()
|
||||
self._last_run_at: float | None = None
|
||||
self._last_success_at: float | None = None
|
||||
self._last_error: str = ""
|
||||
self._last_cycle_ms: int | None = None
|
||||
self._poll_count = 0
|
||||
self._error_count = 0
|
||||
|
||||
def _config(self) -> PollerConfig:
|
||||
settings = get_settings()
|
||||
return PollerConfig(
|
||||
interval_seconds=max(30, int(getattr(settings, "monitoring_poll_interval_seconds", 300) or 300)),
|
||||
initial_delay_seconds=max(0, int(getattr(settings, "monitoring_poll_initial_delay_seconds", 20) or 20)),
|
||||
metrics_limit=70_000,
|
||||
retention_days=max(1, int(getattr(settings, "monitoring_action_retention_days", 30) or 30)),
|
||||
)
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the background worker if it is not already running."""
|
||||
with self._lock:
|
||||
if self._thread and self._thread.is_alive():
|
||||
return
|
||||
self._stop_event.clear()
|
||||
self._thread = threading.Thread(target=self._run, name="monitoring-poller", daemon=True)
|
||||
self._thread.start()
|
||||
logger.info("Monitoring poller started")
|
||||
|
||||
def stop(self, timeout: float = 5.0) -> None:
|
||||
"""Stop the worker thread and wait briefly for shutdown."""
|
||||
with self._lock:
|
||||
thread = self._thread
|
||||
if not thread:
|
||||
return
|
||||
self._stop_event.set()
|
||||
thread.join(timeout=timeout)
|
||||
if thread.is_alive():
|
||||
logger.warning("Monitoring poller did not stop within %.1fs", timeout)
|
||||
else:
|
||||
logger.info("Monitoring poller stopped")
|
||||
with self._lock:
|
||||
if self._thread is thread:
|
||||
self._thread = None
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
"""Return a small status snapshot for diagnostics and tests."""
|
||||
with self._lock:
|
||||
return {
|
||||
"worker_running": bool(self._thread and self._thread.is_alive()),
|
||||
"stop_requested": self._stop_event.is_set(),
|
||||
"last_run_at": self._last_run_at,
|
||||
"last_success_at": self._last_success_at,
|
||||
"last_error": self._last_error,
|
||||
"last_cycle_ms": self._last_cycle_ms,
|
||||
"poll_count": self._poll_count,
|
||||
"error_count": self._error_count,
|
||||
}
|
||||
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
"""Return status plus the active polling configuration."""
|
||||
data = self.status()
|
||||
config = self._config()
|
||||
data.update(
|
||||
{
|
||||
"interval_seconds": config.interval_seconds,
|
||||
"initial_delay_seconds": config.initial_delay_seconds,
|
||||
"retention_days": config.retention_days,
|
||||
}
|
||||
)
|
||||
return data
|
||||
|
||||
def _run_cycle(self, store: SettingsStore, config: PollerConfig) -> None:
|
||||
start = time.perf_counter()
|
||||
machines = store.list_machines()
|
||||
enabled = [machine for machine in machines if machine.get("enabled")]
|
||||
logger.info("Monitoring poll cycle starting enabled_machines=%s", len(enabled))
|
||||
cycle_errors = 0
|
||||
for machine in enabled:
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
try:
|
||||
snapshot = poll_machine_snapshot(
|
||||
machine,
|
||||
store,
|
||||
metrics_limit=config.metrics_limit,
|
||||
request_id=f"poll:{machine['id']}:{int(time.time())}",
|
||||
)
|
||||
logger.info(
|
||||
"Monitoring poll snapshot machine_id=%s request_id=%s status=%s metrics_samples=%s disk_mount=%s",
|
||||
machine["id"],
|
||||
snapshot.get("request_id"),
|
||||
snapshot.get("status"),
|
||||
snapshot.get("metrics_samples"),
|
||||
snapshot.get("disk_mount"),
|
||||
)
|
||||
except Exception:
|
||||
cycle_errors += 1
|
||||
logger.exception("Monitoring poll snapshot failed machine_id=%s machine_name=%s", machine["id"], machine["name"])
|
||||
retention_seconds = config.retention_days * 24 * 60 * 60
|
||||
cutoff_ts = int(time.time()) - retention_seconds
|
||||
removed = store.prune_machine_actions(cutoff_ts)
|
||||
if removed:
|
||||
logger.info("Pruned %s old monitoring action rows older than %s", removed, cutoff_ts)
|
||||
duration_ms = int((time.perf_counter() - start) * 1000)
|
||||
with self._lock:
|
||||
self._last_run_at = time.time()
|
||||
self._last_cycle_ms = duration_ms
|
||||
self._poll_count += 1
|
||||
if cycle_errors:
|
||||
self._error_count += cycle_errors
|
||||
self._last_error = f"{cycle_errors} machine(s) failed"
|
||||
else:
|
||||
self._last_success_at = self._last_run_at
|
||||
self._last_error = ""
|
||||
logger.info(
|
||||
"Monitoring poll cycle complete enabled_machines=%s errors=%s duration_ms=%s removed_rows=%s",
|
||||
len(enabled),
|
||||
cycle_errors,
|
||||
duration_ms,
|
||||
removed,
|
||||
)
|
||||
|
||||
def _run(self) -> None:
|
||||
config = self._config()
|
||||
if config.initial_delay_seconds:
|
||||
logger.info("Monitoring poller initial delay=%ss", config.initial_delay_seconds)
|
||||
if self._stop_event.wait(config.initial_delay_seconds):
|
||||
return
|
||||
store = get_settings_store()
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
self._run_cycle(store, config)
|
||||
except Exception:
|
||||
with self._lock:
|
||||
self._last_error = "poller cycle failed"
|
||||
self._error_count += 1
|
||||
logger.exception("Monitoring poller cycle failed")
|
||||
if self._stop_event.wait(config.interval_seconds):
|
||||
break
|
||||
|
||||
|
||||
_MONITORING_POLLER = MonitoringPoller()
|
||||
|
||||
|
||||
def get_monitoring_poller() -> MonitoringPoller:
|
||||
return _MONITORING_POLLER
|
||||
@@ -0,0 +1,748 @@
|
||||
"""Persistent application settings stored in a small SQLite database.
|
||||
|
||||
The store manages machine definitions, machine services, and per-machine
|
||||
application configuration so the frontend can present local and remote targets
|
||||
in the same UI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from io import StringIO
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import paramiko
|
||||
from media_library_viewer_api.config import get_settings
|
||||
|
||||
DEFAULT_SETTINGS_PATH = Path(".cache/media_library_viewer/settings.sqlite")
|
||||
LOCAL_MACHINE_ID = "local"
|
||||
DEFAULT_SERVICES = ["monitoring", "files", "jellyfin"]
|
||||
|
||||
|
||||
def _default_local_machine() -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
return {
|
||||
"id": LOCAL_MACHINE_ID,
|
||||
"name": "This machine",
|
||||
"mode": "local",
|
||||
"enabled": True,
|
||||
"services": list(DEFAULT_SERVICES),
|
||||
"host": "",
|
||||
"port": 22,
|
||||
"username": "",
|
||||
"key_directory": "",
|
||||
"key_name": "",
|
||||
"ssh_key_id": "",
|
||||
"ssh_private_key_set": False,
|
||||
"ssh_private_key_passphrase_set": False,
|
||||
"password_set": False,
|
||||
"media_root": settings.media_root,
|
||||
"path_prefix": settings.path_prefix,
|
||||
"jellyfin_url": settings.jellyfin_url,
|
||||
"jellyfin_user_id": settings.jellyfin_user_id,
|
||||
"jellyfin_api_key_set": bool(settings.jellyfin_api_key),
|
||||
"jellyseerr_url": settings.jellyseerr_url,
|
||||
"jellyseerr_api_key_set": bool(settings.jellyseerr_api_key),
|
||||
"notes": "",
|
||||
}
|
||||
|
||||
|
||||
class SettingsStore:
|
||||
"""SQLite-backed settings store for machine definitions and history."""
|
||||
|
||||
def __init__(self, db_path: Path | str = DEFAULT_SETTINGS_PATH):
|
||||
self.db_path = Path(db_path)
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self.db_path, timeout=30)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=30000")
|
||||
return conn
|
||||
|
||||
def init_schema(self) -> None:
|
||||
with self.connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS monitoring_machines (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
mode TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL,
|
||||
config_json TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_monitoring_machines_mode ON monitoring_machines(mode)"
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS monitoring_machine_actions (
|
||||
id TEXT PRIMARY KEY,
|
||||
machine_id TEXT NOT NULL,
|
||||
machine_name TEXT NOT NULL,
|
||||
mode TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
duration_ms INTEGER NOT NULL,
|
||||
request_id TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
error TEXT NOT NULL,
|
||||
stdout_tail TEXT NOT NULL,
|
||||
stderr_tail TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS ssh_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
private_key TEXT NOT NULL,
|
||||
passphrase TEXT NOT NULL,
|
||||
notes TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS saved_tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
task_type TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL,
|
||||
default_machine_id TEXT NOT NULL,
|
||||
notes TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_saved_tasks_name ON saved_tasks(name)"
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS saved_task_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
task_name TEXT NOT NULL,
|
||||
machine_id TEXT NOT NULL,
|
||||
machine_name TEXT NOT NULL,
|
||||
task_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
duration_ms INTEGER NOT NULL,
|
||||
request_id TEXT NOT NULL,
|
||||
stdout_tail TEXT NOT NULL,
|
||||
stderr_tail TEXT NOT NULL,
|
||||
error TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_saved_task_runs_task_time ON saved_task_runs(task_id, created_at DESC)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_monitoring_machine_actions_machine_time ON monitoring_machine_actions(machine_id, created_at DESC)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_monitoring_machine_actions_action_status ON monitoring_machine_actions(action, status)"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_services(value: Any, fallback: list[str] | None = None) -> list[str]:
|
||||
if isinstance(value, str):
|
||||
items = [part.strip() for part in value.split(",")]
|
||||
elif isinstance(value, list):
|
||||
items = [str(part).strip() for part in value]
|
||||
else:
|
||||
items = list(fallback or DEFAULT_SERVICES)
|
||||
services = [item for item in items if item]
|
||||
if not services:
|
||||
services = list(fallback or DEFAULT_SERVICES)
|
||||
deduped: list[str] = []
|
||||
for service in services:
|
||||
if service not in deduped:
|
||||
deduped.append(service)
|
||||
return deduped
|
||||
|
||||
def _row_to_machine(self, row: sqlite3.Row) -> dict[str, Any]:
|
||||
data = json.loads(row["config_json"])
|
||||
services = self._normalize_services(data.get("services"), DEFAULT_SERVICES if row["id"] == LOCAL_MACHINE_ID else [])
|
||||
return {
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"mode": row["mode"],
|
||||
"enabled": bool(row["enabled"]),
|
||||
"services": services,
|
||||
"host": data.get("host", ""),
|
||||
"port": int(data.get("port", 22) or 22),
|
||||
"username": data.get("username", ""),
|
||||
"key_directory": data.get("key_directory", ""),
|
||||
"key_name": data.get("key_name", ""),
|
||||
"ssh_key_id": data.get("ssh_key_id", ""),
|
||||
"ssh_private_key_set": bool(data.get("ssh_private_key")),
|
||||
"ssh_private_key_passphrase_set": bool(data.get("ssh_private_key_passphrase")),
|
||||
"password_set": bool(data.get("password")),
|
||||
"media_root": data.get("media_root", ""),
|
||||
"path_prefix": data.get("path_prefix", ""),
|
||||
"jellyfin_url": data.get("jellyfin_url", ""),
|
||||
"jellyfin_user_id": data.get("jellyfin_user_id", ""),
|
||||
"jellyfin_api_key_set": bool(data.get("jellyfin_api_key")),
|
||||
"jellyseerr_url": data.get("jellyseerr_url", ""),
|
||||
"jellyseerr_api_key_set": bool(data.get("jellyseerr_api_key")),
|
||||
"notes": data.get("notes", ""),
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
|
||||
def _normalize_machine_payload(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
machine_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
current = self.get_machine(machine_id) if machine_id else None
|
||||
machine_id = str(payload.get("id") or machine_id or uuid.uuid4().hex[:12]).strip() or uuid.uuid4().hex[:12]
|
||||
mode = str(payload.get("mode") or (current or {}).get("mode") or "local").strip().lower()
|
||||
if mode not in {"local", "ssh"}:
|
||||
mode = "local"
|
||||
enabled = bool(payload.get("enabled", (current or {}).get("enabled", True)))
|
||||
name = str(payload.get("name") or (current or {}).get("name") or "").strip() or (
|
||||
"This machine" if mode == "local" else machine_id
|
||||
)
|
||||
services = self._normalize_services(payload.get("services"), (current or {}).get("services", []))
|
||||
host = str(payload.get("host") if payload.get("host") is not None else (current or {}).get("host", "") or "").strip()
|
||||
port = int(payload.get("port") or (current or {}).get("port", 22) or 22)
|
||||
username = str(payload.get("username") if payload.get("username") is not None else (current or {}).get("username", "") or "").strip()
|
||||
key_directory = str(payload.get("key_directory") if payload.get("key_directory") is not None else (current or {}).get("key_directory", "") or "").strip()
|
||||
key_name = str(payload.get("key_name") if payload.get("key_name") is not None else (current or {}).get("key_name", "") or "").strip()
|
||||
ssh_key_id = str(payload.get("ssh_key_id") if payload.get("ssh_key_id") is not None else (current or {}).get("ssh_key_id", "") or "").strip()
|
||||
ssh_private_key = payload.get("ssh_private_key")
|
||||
if ssh_private_key in (None, ""):
|
||||
ssh_private_key = (current or {}).get("ssh_private_key", "")
|
||||
ssh_private_key = str(ssh_private_key or "")
|
||||
ssh_private_key_passphrase = payload.get("ssh_private_key_passphrase")
|
||||
if ssh_private_key_passphrase in (None, ""):
|
||||
ssh_private_key_passphrase = (current or {}).get("ssh_private_key_passphrase", "")
|
||||
ssh_private_key_passphrase = str(ssh_private_key_passphrase or "")
|
||||
password = payload.get("password")
|
||||
if password in (None, ""):
|
||||
password = (current or {}).get("password", "")
|
||||
password = str(password or "")
|
||||
media_root = str(payload.get("media_root") if payload.get("media_root") is not None else (current or {}).get("media_root", "") or "").strip()
|
||||
path_prefix = str(payload.get("path_prefix") if payload.get("path_prefix") is not None else (current or {}).get("path_prefix", "") or "").strip()
|
||||
jellyfin_url = str(payload.get("jellyfin_url") if payload.get("jellyfin_url") is not None else (current or {}).get("jellyfin_url", "") or "").strip()
|
||||
jellyfin_user_id = str(payload.get("jellyfin_user_id") if payload.get("jellyfin_user_id") is not None else (current or {}).get("jellyfin_user_id", "") or "").strip()
|
||||
jellyfin_api_key = payload.get("jellyfin_api_key")
|
||||
if jellyfin_api_key in (None, ""):
|
||||
jellyfin_api_key = (current or {}).get("jellyfin_api_key", "")
|
||||
jellyfin_api_key = str(jellyfin_api_key or "")
|
||||
jellyseerr_url = str(payload.get("jellyseerr_url") if payload.get("jellyseerr_url") is not None else (current or {}).get("jellyseerr_url", "") or "").strip()
|
||||
jellyseerr_api_key = payload.get("jellyseerr_api_key")
|
||||
if jellyseerr_api_key in (None, ""):
|
||||
jellyseerr_api_key = (current or {}).get("jellyseerr_api_key", "")
|
||||
jellyseerr_api_key = str(jellyseerr_api_key or "")
|
||||
notes = str(payload.get("notes") if payload.get("notes") is not None else (current or {}).get("notes", "") or "").strip()
|
||||
if mode == "local":
|
||||
host = host or "localhost"
|
||||
username = username or ""
|
||||
return {
|
||||
"id": machine_id,
|
||||
"name": name,
|
||||
"mode": mode,
|
||||
"enabled": enabled,
|
||||
"services": services,
|
||||
"host": host,
|
||||
"port": port,
|
||||
"username": username,
|
||||
"key_directory": key_directory,
|
||||
"key_name": key_name,
|
||||
"ssh_key_id": ssh_key_id,
|
||||
"ssh_private_key": ssh_private_key,
|
||||
"ssh_private_key_passphrase": ssh_private_key_passphrase,
|
||||
"password": password,
|
||||
"media_root": media_root,
|
||||
"path_prefix": path_prefix,
|
||||
"jellyfin_url": jellyfin_url,
|
||||
"jellyfin_user_id": jellyfin_user_id,
|
||||
"jellyfin_api_key": jellyfin_api_key,
|
||||
"jellyseerr_url": jellyseerr_url,
|
||||
"jellyseerr_api_key": jellyseerr_api_key,
|
||||
"notes": notes,
|
||||
}
|
||||
|
||||
def ensure_defaults(self) -> None:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone()
|
||||
if row and int(row[0]) > 0:
|
||||
return
|
||||
machine = _default_local_machine()
|
||||
now = int(time.time())
|
||||
config = {
|
||||
"services": machine["services"],
|
||||
"host": machine["host"],
|
||||
"port": machine["port"],
|
||||
"username": machine["username"],
|
||||
"key_directory": machine["key_directory"],
|
||||
"key_name": machine["key_name"],
|
||||
"ssh_key_id": machine.get("ssh_key_id", ""),
|
||||
"ssh_private_key": "",
|
||||
"ssh_private_key_passphrase": "",
|
||||
"password": "",
|
||||
"media_root": machine["media_root"],
|
||||
"path_prefix": machine["path_prefix"],
|
||||
"jellyfin_url": machine["jellyfin_url"],
|
||||
"jellyfin_user_id": machine["jellyfin_user_id"],
|
||||
"jellyfin_api_key": "",
|
||||
"jellyseerr_url": machine["jellyseerr_url"],
|
||||
"jellyseerr_api_key": "",
|
||||
"notes": machine["notes"],
|
||||
}
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO monitoring_machines (id, name, mode, enabled, config_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
machine["id"],
|
||||
machine["name"],
|
||||
machine["mode"],
|
||||
1,
|
||||
json.dumps(config),
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
|
||||
def list_machines(self) -> list[dict[str, Any]]:
|
||||
self.ensure_defaults()
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM monitoring_machines ORDER BY CASE WHEN id = ? THEN 0 ELSE 1 END, name COLLATE NOCASE",
|
||||
(LOCAL_MACHINE_ID,),
|
||||
).fetchall()
|
||||
return [self._row_to_machine(row) for row in rows]
|
||||
|
||||
def get_machine(self, machine_id: str | None) -> dict[str, Any] | None:
|
||||
if not machine_id:
|
||||
return None
|
||||
self.ensure_defaults()
|
||||
with self.connect() as conn:
|
||||
row = conn.execute("SELECT * FROM monitoring_machines WHERE id = ?", (machine_id,)).fetchone()
|
||||
return self._row_to_machine(row) if row else None
|
||||
|
||||
def get_machine_config(self, machine_id: str | None) -> dict[str, Any] | None:
|
||||
"""Return the full machine config including secrets."""
|
||||
if not machine_id:
|
||||
return None
|
||||
self.ensure_defaults()
|
||||
with self.connect() as conn:
|
||||
row = conn.execute("SELECT * FROM monitoring_machines WHERE id = ?", (machine_id,)).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
data = json.loads(row["config_json"])
|
||||
return {
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"mode": row["mode"],
|
||||
"enabled": bool(row["enabled"]),
|
||||
"services": self._normalize_services(data.get("services"), DEFAULT_SERVICES if row["id"] == LOCAL_MACHINE_ID else []),
|
||||
"host": data.get("host", ""),
|
||||
"port": int(data.get("port", 22) or 22),
|
||||
"username": data.get("username", ""),
|
||||
"key_directory": data.get("key_directory", ""),
|
||||
"key_name": data.get("key_name", ""),
|
||||
"ssh_key_id": data.get("ssh_key_id", ""),
|
||||
"ssh_private_key": data.get("ssh_private_key", ""),
|
||||
"ssh_private_key_passphrase": data.get("ssh_private_key_passphrase", ""),
|
||||
"password": data.get("password", ""),
|
||||
"media_root": data.get("media_root", ""),
|
||||
"path_prefix": data.get("path_prefix", ""),
|
||||
"jellyfin_url": data.get("jellyfin_url", ""),
|
||||
"jellyfin_user_id": data.get("jellyfin_user_id", ""),
|
||||
"jellyfin_api_key": data.get("jellyfin_api_key", ""),
|
||||
"jellyseerr_url": data.get("jellyseerr_url", ""),
|
||||
"jellyseerr_api_key": data.get("jellyseerr_api_key", ""),
|
||||
"notes": data.get("notes", ""),
|
||||
}
|
||||
|
||||
def list_machines_for_service(self, service: str) -> list[dict[str, Any]]:
|
||||
return [machine for machine in self.list_machines() if service in machine.get("services", []) and machine.get("enabled")]
|
||||
|
||||
def get_machine_for_service(self, service: str, machine_id: str | None = None) -> dict[str, Any] | None:
|
||||
if machine_id:
|
||||
machine = self.get_machine(machine_id)
|
||||
if machine and service in machine.get("services", []) and machine.get("enabled"):
|
||||
return machine
|
||||
return machine if machine else None
|
||||
machines = self.list_machines_for_service(service)
|
||||
return machines[0] if machines else None
|
||||
|
||||
def upsert_machine(self, payload: dict[str, Any], machine_id: str | None = None) -> dict[str, Any]:
|
||||
self.init_schema()
|
||||
machine = self._normalize_machine_payload(payload, machine_id)
|
||||
now = int(time.time())
|
||||
config = {
|
||||
"services": machine["services"],
|
||||
"host": machine["host"],
|
||||
"port": machine["port"],
|
||||
"username": machine["username"],
|
||||
"key_directory": machine["key_directory"],
|
||||
"key_name": machine["key_name"],
|
||||
"ssh_key_id": machine.get("ssh_key_id", ""),
|
||||
"ssh_private_key": machine["ssh_private_key"],
|
||||
"ssh_private_key_passphrase": machine["ssh_private_key_passphrase"],
|
||||
"password": machine["password"],
|
||||
"media_root": machine["media_root"],
|
||||
"path_prefix": machine["path_prefix"],
|
||||
"jellyfin_url": machine["jellyfin_url"],
|
||||
"jellyfin_user_id": machine["jellyfin_user_id"],
|
||||
"jellyfin_api_key": machine["jellyfin_api_key"],
|
||||
"jellyseerr_url": machine["jellyseerr_url"],
|
||||
"jellyseerr_api_key": machine["jellyseerr_api_key"],
|
||||
"notes": machine["notes"],
|
||||
}
|
||||
with self.connect() as conn:
|
||||
existing = conn.execute("SELECT created_at FROM monitoring_machines WHERE id = ?", (machine["id"],)).fetchone()
|
||||
created_at = int(existing[0]) if existing else now
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO monitoring_machines (id, name, mode, enabled, config_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
mode = excluded.mode,
|
||||
enabled = excluded.enabled,
|
||||
config_json = excluded.config_json,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(
|
||||
machine["id"],
|
||||
machine["name"],
|
||||
machine["mode"],
|
||||
1 if machine["enabled"] else 0,
|
||||
json.dumps(config),
|
||||
created_at,
|
||||
now,
|
||||
),
|
||||
)
|
||||
return self.get_machine(machine["id"]) or machine
|
||||
|
||||
def delete_machine(self, machine_id: str) -> None:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
conn.execute("DELETE FROM monitoring_machines WHERE id = ?", (machine_id,))
|
||||
|
||||
def record_machine_action(
|
||||
self,
|
||||
machine: dict[str, Any],
|
||||
action: str,
|
||||
status: str,
|
||||
*,
|
||||
duration_ms: int,
|
||||
request_id: str = "",
|
||||
message: str = "",
|
||||
error: str = "",
|
||||
stdout_tail: str = "",
|
||||
stderr_tail: str = "",
|
||||
) -> None:
|
||||
"""Store a compact action history row for a machine operation."""
|
||||
self.init_schema()
|
||||
now = int(time.time())
|
||||
with self.connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO monitoring_machine_actions
|
||||
(id, machine_id, machine_name, mode, action, status, created_at, duration_ms, request_id, message, error, stdout_tail, stderr_tail)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"""
|
||||
,
|
||||
(
|
||||
uuid.uuid4().hex,
|
||||
str(machine.get("id") or ""),
|
||||
str(machine.get("name") or ""),
|
||||
str(machine.get("mode") or "local"),
|
||||
action,
|
||||
status,
|
||||
now,
|
||||
duration_ms,
|
||||
request_id,
|
||||
message,
|
||||
error,
|
||||
stdout_tail,
|
||||
stderr_tail,
|
||||
),
|
||||
)
|
||||
|
||||
def list_machine_actions(
|
||||
self,
|
||||
machine_id: str,
|
||||
*,
|
||||
limit: int = 20,
|
||||
action: str | None = None,
|
||||
status: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
self.init_schema()
|
||||
clauses = ["machine_id = ?"]
|
||||
params: list[Any] = [machine_id]
|
||||
if action:
|
||||
clauses.append("action = ?")
|
||||
params.append(action)
|
||||
if status:
|
||||
clauses.append("status = ?")
|
||||
params.append(status)
|
||||
sql = (
|
||||
"SELECT machine_id, machine_name, mode, action, status, created_at, duration_ms, request_id, message, error, stdout_tail, stderr_tail "
|
||||
f"FROM monitoring_machine_actions WHERE {' AND '.join(clauses)} ORDER BY created_at DESC LIMIT ?"
|
||||
)
|
||||
params.append(max(1, min(int(limit), 200)))
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def prune_machine_actions(self, older_than_ts: int) -> int:
|
||||
"""Delete action history rows older than the given timestamp."""
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
cur = conn.execute(
|
||||
"DELETE FROM monitoring_machine_actions WHERE created_at < ?",
|
||||
(int(older_than_ts),),
|
||||
)
|
||||
return int(cur.rowcount or 0)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _private_key_summary(private_key: str) -> dict[str, str]:
|
||||
if not private_key:
|
||||
return {"public_key": "", "fingerprint": ""}
|
||||
key_classes = [paramiko.Ed25519Key, paramiko.RSAKey, paramiko.ECDSAKey]
|
||||
for key_class in key_classes:
|
||||
try:
|
||||
key = key_class.from_private_key(StringIO(private_key))
|
||||
fingerprint = ":".join(f"{b:02x}" for b in key.get_fingerprint())
|
||||
return {"public_key": f"{key.get_name()} {key.get_base64()}", "fingerprint": fingerprint}
|
||||
except Exception:
|
||||
continue
|
||||
return {"public_key": "", "fingerprint": ""}
|
||||
|
||||
def _row_to_ssh_key(self, row: sqlite3.Row, usage_count: int = 0) -> dict[str, Any]:
|
||||
summary = self._private_key_summary(str(row["private_key"] or ""))
|
||||
return {
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"private_key_set": bool(row["private_key"]),
|
||||
"passphrase_set": bool(row["passphrase"]),
|
||||
"public_key": summary["public_key"],
|
||||
"fingerprint": summary["fingerprint"],
|
||||
"usage_count": usage_count,
|
||||
"notes": row["notes"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
|
||||
def _normalize_ssh_key_payload(self, payload: dict[str, Any], key_id: str | None = None) -> dict[str, Any]:
|
||||
current = self.get_ssh_key(key_id) if key_id else None
|
||||
key_id = str(payload.get("id") or key_id or uuid.uuid4().hex[:12]).strip() or uuid.uuid4().hex[:12]
|
||||
name = str(payload.get("name") or (current or {}).get("name") or key_id).strip() or key_id
|
||||
private_key = payload.get("private_key")
|
||||
if private_key in (None, ""):
|
||||
private_key = (current or {}).get("private_key", "")
|
||||
private_key = str(private_key or "")
|
||||
passphrase = payload.get("passphrase")
|
||||
if passphrase in (None, ""):
|
||||
passphrase = (current or {}).get("passphrase", "")
|
||||
passphrase = str(passphrase or "")
|
||||
notes = str(payload.get("notes") if payload.get("notes") is not None else (current or {}).get("notes", "") or "").strip()
|
||||
return {"id": key_id, "name": name, "private_key": private_key, "passphrase": passphrase, "notes": notes}
|
||||
|
||||
def list_ssh_keys(self) -> list[dict[str, Any]]:
|
||||
self.init_schema()
|
||||
machines = self.list_machines()
|
||||
usage_counts: dict[str, int] = {}
|
||||
for machine in machines:
|
||||
ssh_key_id = str(machine.get("ssh_key_id") or "").strip()
|
||||
if ssh_key_id:
|
||||
usage_counts[ssh_key_id] = usage_counts.get(ssh_key_id, 0) + 1
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute("SELECT * FROM ssh_keys ORDER BY name COLLATE NOCASE").fetchall()
|
||||
return [self._row_to_ssh_key(row, usage_counts.get(row["id"], 0)) for row in rows]
|
||||
|
||||
def get_ssh_key(self, key_id: str | None) -> dict[str, Any] | None:
|
||||
if not key_id:
|
||||
return None
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
row = conn.execute("SELECT * FROM ssh_keys WHERE id = ?", (key_id,)).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
summary = self._private_key_summary(str(row["private_key"] or ""))
|
||||
return {"id": row["id"], "name": row["name"], "private_key": row["private_key"], "passphrase": row["passphrase"], "notes": row["notes"], "public_key": summary["public_key"], "fingerprint": summary["fingerprint"]}
|
||||
|
||||
def upsert_ssh_key(self, payload: dict[str, Any], key_id: str | None = None) -> dict[str, Any]:
|
||||
self.init_schema()
|
||||
key = self._normalize_ssh_key_payload(payload, key_id)
|
||||
now = int(time.time())
|
||||
with self.connect() as conn:
|
||||
existing = conn.execute("SELECT created_at FROM ssh_keys WHERE id = ?", (key["id"],)).fetchone()
|
||||
created_at = int(existing[0]) if existing else now
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO ssh_keys (id, name, private_key, passphrase, notes, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
private_key = excluded.private_key,
|
||||
passphrase = excluded.passphrase,
|
||||
notes = excluded.notes,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(key["id"], key["name"], key["private_key"], key["passphrase"], key["notes"], created_at, now),
|
||||
)
|
||||
return self.get_ssh_key(key["id"]) or key
|
||||
|
||||
def delete_ssh_key(self, key_id: str) -> None:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
conn.execute("DELETE FROM ssh_keys WHERE id = ?", (key_id,))
|
||||
|
||||
|
||||
|
||||
def _row_to_task(self, row: sqlite3.Row) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"task_type": row["task_type"],
|
||||
"content": row["content"],
|
||||
"enabled": bool(row["enabled"]),
|
||||
"default_machine_id": row["default_machine_id"],
|
||||
"notes": row["notes"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
|
||||
def _normalize_task_payload(self, payload: dict[str, Any], task_id: str | None = None) -> dict[str, Any]:
|
||||
current = self.get_task(task_id) if task_id else None
|
||||
task_id = str(payload.get("id") or task_id or uuid.uuid4().hex[:12]).strip() or uuid.uuid4().hex[:12]
|
||||
name = str(payload.get("name") or (current or {}).get("name") or task_id).strip() or task_id
|
||||
task_type = str(payload.get("task_type") or (current or {}).get("task_type") or "shell").strip().lower()
|
||||
if task_type not in {"shell", "python"}:
|
||||
task_type = "shell"
|
||||
content = str(payload.get("content") if payload.get("content") is not None else (current or {}).get("content", "") or "")
|
||||
enabled = bool(payload.get("enabled", (current or {}).get("enabled", True)))
|
||||
default_machine_id = str(payload.get("default_machine_id") if payload.get("default_machine_id") is not None else (current or {}).get("default_machine_id", "") or "").strip()
|
||||
notes = str(payload.get("notes") if payload.get("notes") is not None else (current or {}).get("notes", "") or "").strip()
|
||||
return {"id": task_id, "name": name, "task_type": task_type, "content": content, "enabled": enabled, "default_machine_id": default_machine_id, "notes": notes}
|
||||
|
||||
def list_tasks(self) -> list[dict[str, Any]]:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute("SELECT * FROM saved_tasks ORDER BY name COLLATE NOCASE").fetchall()
|
||||
return [self._row_to_task(row) for row in rows]
|
||||
|
||||
def get_task(self, task_id: str | None) -> dict[str, Any] | None:
|
||||
if not task_id:
|
||||
return None
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
row = conn.execute("SELECT * FROM saved_tasks WHERE id = ?", (task_id,)).fetchone()
|
||||
return self._row_to_task(row) if row else None
|
||||
|
||||
def upsert_task(self, payload: dict[str, Any], task_id: str | None = None) -> dict[str, Any]:
|
||||
self.init_schema()
|
||||
task = self._normalize_task_payload(payload, task_id)
|
||||
now = int(time.time())
|
||||
with self.connect() as conn:
|
||||
existing = conn.execute("SELECT created_at FROM saved_tasks WHERE id = ?", (task["id"],)).fetchone()
|
||||
created_at = int(existing[0]) if existing else now
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO saved_tasks (id, name, task_type, content, enabled, default_machine_id, notes, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
task_type = excluded.task_type,
|
||||
content = excluded.content,
|
||||
enabled = excluded.enabled,
|
||||
default_machine_id = excluded.default_machine_id,
|
||||
notes = excluded.notes,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(task["id"], task["name"], task["task_type"], task["content"], 1 if task["enabled"] else 0, task["default_machine_id"], task["notes"], created_at, now),
|
||||
)
|
||||
return self.get_task(task["id"]) or task
|
||||
|
||||
def delete_task(self, task_id: str) -> None:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
conn.execute("DELETE FROM saved_tasks WHERE id = ?", (task_id,))
|
||||
|
||||
def list_task_runs(self, task_id: str, *, limit: int = 10) -> list[dict[str, Any]]:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM saved_task_runs WHERE task_id = ? ORDER BY created_at DESC LIMIT ?",
|
||||
(task_id, max(1, min(int(limit), 50))),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def record_task_run(
|
||||
self,
|
||||
task: dict[str, Any],
|
||||
status: str,
|
||||
*,
|
||||
machine_id: str,
|
||||
machine_name: str,
|
||||
task_type: str,
|
||||
duration_ms: int,
|
||||
request_id: str = "",
|
||||
stdout_tail: str = "",
|
||||
stderr_tail: str = "",
|
||||
error: str = "",
|
||||
) -> None:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO saved_task_runs (id, task_id, task_name, machine_id, machine_name, task_type, status, created_at, duration_ms, request_id, stdout_tail, stderr_tail, error)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
uuid.uuid4().hex,
|
||||
str(task.get("id") or ""),
|
||||
str(task.get("name") or ""),
|
||||
machine_id,
|
||||
machine_name,
|
||||
task_type,
|
||||
status,
|
||||
int(time.time()),
|
||||
duration_ms,
|
||||
request_id,
|
||||
stdout_tail,
|
||||
stderr_tail,
|
||||
error,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
_store: SettingsStore | None = None
|
||||
|
||||
|
||||
def get_settings_store() -> SettingsStore:
|
||||
global _store
|
||||
if _store is None:
|
||||
_store = SettingsStore()
|
||||
return _store
|
||||
@@ -0,0 +1,46 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from media_library_viewer_api.services.monitoring_actions import poll_machine_snapshot
|
||||
|
||||
|
||||
def test_poll_machine_snapshot_records_history_entries():
|
||||
store = MagicMock()
|
||||
machine = {
|
||||
"id": "local",
|
||||
"name": "This machine",
|
||||
"mode": "local",
|
||||
"media_root": "/srv/media",
|
||||
}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"media_library_viewer_api.services.monitoring_actions.build_machine_client",
|
||||
return_value=object(),
|
||||
) as build_client,
|
||||
patch(
|
||||
"media_library_viewer_api.services.monitoring_actions.resource_collector_status",
|
||||
return_value="running pid=123",
|
||||
) as status_fn,
|
||||
patch(
|
||||
"media_library_viewer_api.services.monitoring_actions.read_resource_metrics",
|
||||
return_value=[{"ts": 1.0}, {"ts": 2.0}],
|
||||
) as metrics_fn,
|
||||
patch(
|
||||
"media_library_viewer_api.services.monitoring_actions.disk_space",
|
||||
return_value={"mount": "/srv/media", "used_pct": "12.5%"},
|
||||
) as disk_fn,
|
||||
):
|
||||
result = poll_machine_snapshot(machine, store, metrics_limit=123, request_id="poll:test")
|
||||
|
||||
assert result["request_id"] == "poll:test"
|
||||
assert result["metrics_samples"] == 2
|
||||
assert result["disk_mount"] == "/srv/media"
|
||||
build_client.assert_called_once_with(machine)
|
||||
status_fn.assert_called_once()
|
||||
metrics_fn.assert_called_once_with(build_client.return_value, max_lines=123)
|
||||
disk_fn.assert_called_once_with(build_client.return_value, "/srv/media")
|
||||
assert store.record_machine_action.call_count == 3
|
||||
recorded_actions = [call.args[1] for call in store.record_machine_action.call_args_list]
|
||||
assert recorded_actions == ["status lookup", "metrics read", "disk lookup for /srv/media"]
|
||||
assert all(call.kwargs["request_id"] == "poll:test" for call in store.record_machine_action.call_args_list)
|
||||
assert all(call.args[2] == "ok" for call in store.record_machine_action.call_args_list)
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Box, Button, DialogActions } from "@mui/material";
|
||||
|
||||
interface DialogFooterProps {
|
||||
onCancel: () => void;
|
||||
cancelLabel?: string;
|
||||
onConfirm: () => void;
|
||||
confirmLabel: string;
|
||||
confirmBusyLabel?: string;
|
||||
confirmDisabled?: boolean;
|
||||
confirmColor?: "primary" | "error" | "warning" | "success" | "inherit";
|
||||
confirmVariant?: "contained" | "outlined" | "text";
|
||||
confirmStartIcon?: ReactNode;
|
||||
secondaryAction?: ReactNode;
|
||||
}
|
||||
|
||||
export function DialogFooter({
|
||||
onCancel,
|
||||
cancelLabel = "Cancel",
|
||||
onConfirm,
|
||||
confirmLabel,
|
||||
confirmBusyLabel,
|
||||
confirmDisabled,
|
||||
confirmColor = "primary",
|
||||
confirmVariant = "contained",
|
||||
confirmStartIcon,
|
||||
secondaryAction,
|
||||
}: DialogFooterProps) {
|
||||
return (
|
||||
<DialogActions sx={{ px: 3, py: 2 }}>
|
||||
<Button onClick={onCancel}>{cancelLabel}</Button>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
{secondaryAction}
|
||||
<Button
|
||||
variant={confirmVariant}
|
||||
color={confirmColor}
|
||||
disabled={confirmDisabled}
|
||||
startIcon={confirmStartIcon}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmBusyLabel ?? confirmLabel}
|
||||
</Button>
|
||||
</Box>
|
||||
</DialogActions>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||
import { IconButton } from "@mui/material";
|
||||
|
||||
interface HoverEditButtonProps {
|
||||
onClick: () => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function HoverEditButton({
|
||||
onClick,
|
||||
label = "Edit",
|
||||
}: HoverEditButtonProps) {
|
||||
return (
|
||||
<IconButton
|
||||
className="rail-edit"
|
||||
aria-label={label}
|
||||
size="small"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
sx={{
|
||||
opacity: 0,
|
||||
transition: "opacity 120ms ease",
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
<EditOutlinedIcon fontSize="inherit" />
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
FormControl,
|
||||
Grid,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { MetricCard } from "./MetricCard";
|
||||
import { MonitoringCharts } from "./MonitoringCharts";
|
||||
import type { MonitoringMachine } from "../types";
|
||||
import {
|
||||
useCollectorControls,
|
||||
useDiskSpace,
|
||||
useMachineActions,
|
||||
useMonitoringMetrics,
|
||||
useMonitoringStatus,
|
||||
} from "../hooks/useMonitoring";
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!bytes || bytes === 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let value = bytes;
|
||||
let unitIdx = 0;
|
||||
while (value >= 1000 && unitIdx < units.length - 1) {
|
||||
value /= 1000;
|
||||
unitIdx++;
|
||||
}
|
||||
return `${value.toFixed(1)} ${units[unitIdx]}`;
|
||||
}
|
||||
|
||||
function formatRate(bytes: number): string {
|
||||
return `${formatBytes(bytes)}/s`;
|
||||
}
|
||||
|
||||
function avg(arr: number[]) {
|
||||
return arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : 0;
|
||||
}
|
||||
function max(arr: number[]) {
|
||||
return arr.length ? Math.max(...arr) : 0;
|
||||
}
|
||||
|
||||
function formatActionTime(epochSeconds: number): string {
|
||||
return new Date(epochSeconds * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
function ErrorBanner({ label, error }: { label: string; error: unknown }) {
|
||||
if (!error) return null;
|
||||
return (
|
||||
<Alert severity="error">
|
||||
{label}: {String(error)}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
export function MachineMonitoringSection({
|
||||
machine,
|
||||
}: {
|
||||
machine: MonitoringMachine;
|
||||
}) {
|
||||
const statusQuery = useMonitoringStatus(machine.id, machine.enabled);
|
||||
const metricsQuery = useMonitoringMetrics(machine.id, machine.enabled);
|
||||
const diskQuery = useDiskSpace(machine.id, machine.enabled);
|
||||
const { start, stop, restart } = useCollectorControls(machine.id);
|
||||
const actionsQuery = useMachineActions(machine.id, machine.enabled);
|
||||
const [actionFilter, setActionFilter] = useState("all");
|
||||
const [resultFilter, setResultFilter] = useState("all");
|
||||
|
||||
const status = statusQuery.data;
|
||||
const metrics = metricsQuery.data;
|
||||
const disk = diskQuery.data;
|
||||
const samples = metrics?.samples ?? [];
|
||||
const latest = samples.at(-1);
|
||||
const cpuArr = samples.map((s) => s.cpu_pct);
|
||||
const iowArr = samples.map((s) => s.iowait_pct ?? 0);
|
||||
const memArr = samples.map((s) => s.mem_pct);
|
||||
const netDownArr = samples.map((s) => s.net_rx_bytes_per_sec);
|
||||
const netUpArr = samples.map((s) => s.net_tx_bytes_per_sec);
|
||||
const diskReadArr = samples.map((s) => s.disk_read_bps);
|
||||
const diskWriteArr = samples.map((s) => s.disk_write_bps);
|
||||
const actions = actionsQuery.data?.items ?? [];
|
||||
const visibleActions = useMemo(
|
||||
() =>
|
||||
actions.filter((action) => {
|
||||
const actionMatches =
|
||||
actionFilter === "all" || action.action === actionFilter;
|
||||
const resultMatches =
|
||||
resultFilter === "all" || action.status === resultFilter;
|
||||
return actionMatches && resultMatches;
|
||||
}),
|
||||
[actions, actionFilter, resultFilter],
|
||||
);
|
||||
const hasQueryError =
|
||||
statusQuery.error ||
|
||||
metricsQuery.error ||
|
||||
diskQuery.error ||
|
||||
actionsQuery.error;
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1.5}
|
||||
sx={{ alignItems: "center", flexWrap: "wrap" }}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>
|
||||
{machine.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{machine.mode === "local"
|
||||
? "Local API host"
|
||||
: `${machine.username || "user"}@${machine.host || "host"}:${machine.port}`}
|
||||
{machine.media_root ? ` · media root ${machine.media_root}` : ""}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip label={machine.mode} variant="outlined" />
|
||||
<Chip
|
||||
label={machine.enabled ? "Enabled" : "Disabled"}
|
||||
color={machine.enabled ? "success" : "default"}
|
||||
variant="outlined"
|
||||
/>
|
||||
<Chip
|
||||
label={status?.status ?? "unknown"}
|
||||
color={status?.status?.includes("running") ? "success" : "primary"}
|
||||
variant="outlined"
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => start.mutate()}
|
||||
disabled={start.isPending || !machine.enabled}
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => restart.mutate()}
|
||||
disabled={restart.isPending || !machine.enabled}
|
||||
>
|
||||
Restart
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => stop.mutate()}
|
||||
disabled={stop.isPending || !machine.enabled}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{hasQueryError && (
|
||||
<Stack spacing={1}>
|
||||
<ErrorBanner label="Status" error={statusQuery.error} />
|
||||
<ErrorBanner label="Metrics" error={metricsQuery.error} />
|
||||
<ErrorBanner label="Disk" error={diskQuery.error} />
|
||||
<ErrorBanner label="Recent activity" error={actionsQuery.error} />
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Grid container spacing={1.5}>
|
||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||
<MetricCard
|
||||
label="CPU now"
|
||||
value={latest ? `${latest.cpu_pct.toFixed(1)}%` : "-"}
|
||||
subtext={`avg ${avg(cpuArr).toFixed(1)}%\npeak ${max(cpuArr).toFixed(1)}%`}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||
<MetricCard
|
||||
label="IO Wait"
|
||||
value={latest ? `${(latest.iowait_pct ?? 0).toFixed(1)}%` : "-"}
|
||||
subtext={`avg ${avg(iowArr).toFixed(1)}%\npeak ${max(iowArr).toFixed(1)}%`}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||
<MetricCard
|
||||
label="RAM now"
|
||||
value={latest ? `${latest.mem_pct.toFixed(1)}%` : "-"}
|
||||
subtext={`avg ${avg(memArr).toFixed(1)}%\npeak ${max(memArr).toFixed(1)}%`}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||
<MetricCard
|
||||
label="Net down"
|
||||
value={latest ? formatRate(latest.net_rx_bytes_per_sec) : "-"}
|
||||
subtext={`avg ${formatRate(avg(netDownArr))}\npeak ${formatRate(max(netDownArr))}`}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||
<MetricCard
|
||||
label="Net up"
|
||||
value={latest ? formatRate(latest.net_tx_bytes_per_sec) : "-"}
|
||||
subtext={`avg ${formatRate(avg(netUpArr))}\npeak ${formatRate(max(netUpArr))}`}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||
<MetricCard
|
||||
label="Disk read"
|
||||
value={latest ? formatRate(latest.disk_read_bps) : "-"}
|
||||
subtext={`avg ${formatRate(avg(diskReadArr))}\npeak ${formatRate(max(diskReadArr))}`}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||
<MetricCard
|
||||
label="Disk write"
|
||||
value={latest ? formatRate(latest.disk_write_bps) : "-"}
|
||||
subtext={`avg ${formatRate(avg(diskWriteArr))}\npeak ${formatRate(max(diskWriteArr))}`}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{disk && (
|
||||
<Grid container spacing={1.5}>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<MetricCard label="Disk used" value={formatBytes(disk.used)} />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<MetricCard
|
||||
label="Disk available"
|
||||
value={formatBytes(disk.available)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<MetricCard label="Disk total" value={formatBytes(disk.size)} />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<MetricCard label="Used %" value={disk.used_pct} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<MonitoringCharts samples={samples} />
|
||||
</Box>
|
||||
|
||||
<Stack spacing={1.5}>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{ alignItems: "center", flexWrap: "wrap" }}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
Recent activity
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Collected automatically by the backend poller.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
label={`${visibleActions.length}/${actions.length || 0}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
<FormControl size="small" sx={{ minWidth: 150 }}>
|
||||
<InputLabel>Action</InputLabel>
|
||||
<Select
|
||||
label="Action"
|
||||
value={actionFilter}
|
||||
onChange={(e) => setActionFilter(e.target.value as string)}
|
||||
>
|
||||
<MenuItem value="all">All actions</MenuItem>
|
||||
<MenuItem value="status lookup">Status</MenuItem>
|
||||
<MenuItem value="metrics read">Metrics</MenuItem>
|
||||
<MenuItem value="disk lookup">Disk</MenuItem>
|
||||
<MenuItem value="collector start">Start</MenuItem>
|
||||
<MenuItem value="collector stop">Stop</MenuItem>
|
||||
<MenuItem value="collector restart">Restart</MenuItem>
|
||||
<MenuItem value="collector diagnostics">Diagnostics</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" sx={{ minWidth: 120 }}>
|
||||
<InputLabel>Result</InputLabel>
|
||||
<Select
|
||||
label="Result"
|
||||
value={resultFilter}
|
||||
onChange={(e) => setResultFilter(e.target.value as string)}
|
||||
>
|
||||
<MenuItem value="all">All results</MenuItem>
|
||||
<MenuItem value="ok">OK</MenuItem>
|
||||
<MenuItem value="error">Error</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
{actionsQuery.error && (
|
||||
<Alert severity="error">
|
||||
Recent activity: {String(actionsQuery.error)}
|
||||
</Alert>
|
||||
)}
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Time</TableCell>
|
||||
<TableCell>Action</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell align="right">Duration</TableCell>
|
||||
<TableCell>Message</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{visibleActions.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No activity matches the current filters.
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
visibleActions.map((action) => (
|
||||
<TableRow
|
||||
key={`${action.machine_id}-${action.created_at}-${action.action}-${action.status}`}
|
||||
>
|
||||
<TableCell>{formatActionTime(action.created_at)}</TableCell>
|
||||
<TableCell>{action.action}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
size="small"
|
||||
label={action.status}
|
||||
color={action.status === "ok" ? "success" : "error"}
|
||||
variant="outlined"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">{action.duration_ms} ms</TableCell>
|
||||
<TableCell>
|
||||
{action.message || action.error || "-"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableSortLabel,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import type {
|
||||
MonitoringMachineOverview,
|
||||
MonitoringOverviewResponse,
|
||||
} from "../types";
|
||||
|
||||
type SortKey =
|
||||
| "machine"
|
||||
| "mode"
|
||||
| "status"
|
||||
| "cpu"
|
||||
| "iowait"
|
||||
| "mem"
|
||||
| "net_rx"
|
||||
| "net_tx"
|
||||
| "disk_read"
|
||||
| "disk_write"
|
||||
| "disk_used"
|
||||
| "updated";
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!bytes || bytes === 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let value = bytes;
|
||||
let unitIdx = 0;
|
||||
while (value >= 1000 && unitIdx < units.length - 1) {
|
||||
value /= 1000;
|
||||
unitIdx++;
|
||||
}
|
||||
return `${value.toFixed(1)} ${units[unitIdx]}`;
|
||||
}
|
||||
|
||||
function formatRate(bytes: number): string {
|
||||
return `${formatBytes(bytes)}/s`;
|
||||
}
|
||||
|
||||
function formatTime(epochSeconds: number | null): string {
|
||||
if (!epochSeconds) return "-";
|
||||
return new Date(epochSeconds * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
function formatAge(epochSeconds: number | null): string {
|
||||
if (!epochSeconds) return "-";
|
||||
const diff = Date.now() / 1000 - epochSeconds;
|
||||
if (diff < 60) return `${Math.max(0, Math.round(diff))}s ago`;
|
||||
if (diff < 3600) return `${Math.round(diff / 60)}m ago`;
|
||||
return `${Math.round(diff / 3600)}h ago`;
|
||||
}
|
||||
|
||||
function formatSummary(
|
||||
summary: { avg: number; min: number; max: number } | null,
|
||||
formatter: (value: number) => string,
|
||||
) {
|
||||
if (!summary) return { value: "-", subtext: "" };
|
||||
return {
|
||||
value: formatter(summary.avg),
|
||||
subtext: `Low ${formatter(summary.min)}\nHigh ${formatter(summary.max)}`,
|
||||
};
|
||||
}
|
||||
|
||||
function sortableText(value: string) {
|
||||
return value.toLowerCase();
|
||||
}
|
||||
|
||||
function metricSortValue(
|
||||
row: MonitoringMachineOverview,
|
||||
key: SortKey,
|
||||
): number | string {
|
||||
switch (key) {
|
||||
case "machine":
|
||||
return sortableText(row.machine.name);
|
||||
case "mode":
|
||||
return row.machine.mode;
|
||||
case "status":
|
||||
return sortableText(row.status || row.status_error || "");
|
||||
case "cpu":
|
||||
return row.cpu_summary?.avg ?? -1;
|
||||
case "iowait":
|
||||
return row.iowait_summary?.avg ?? -1;
|
||||
case "mem":
|
||||
return row.mem_summary?.avg ?? -1;
|
||||
case "net_rx":
|
||||
return row.net_rx_summary?.avg ?? -1;
|
||||
case "net_tx":
|
||||
return row.net_tx_summary?.avg ?? -1;
|
||||
case "disk_read":
|
||||
return row.disk_read_summary?.avg ?? -1;
|
||||
case "disk_write":
|
||||
return row.disk_write_summary?.avg ?? -1;
|
||||
case "disk_used":
|
||||
return parseFloat((row.disk?.used_pct || "0").replace("%", "")) || -1;
|
||||
case "updated":
|
||||
return row.latest_sample?.ts ?? -1;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function metricCell(value: string, subtext?: string) {
|
||||
return (
|
||||
<Stack
|
||||
spacing={0.1}
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: "0.95rem",
|
||||
lineHeight: 1.1,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</Typography>
|
||||
{subtext ? (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
whiteSpace: "pre-line",
|
||||
lineHeight: 1.0,
|
||||
fontSize: "0.64rem",
|
||||
opacity: 0.9,
|
||||
}}
|
||||
>
|
||||
{subtext}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function MonitoringOverviewTable({
|
||||
overview,
|
||||
embedded = false,
|
||||
}: {
|
||||
overview?: MonitoringOverviewResponse;
|
||||
embedded?: boolean;
|
||||
}) {
|
||||
const poller = overview?.poller;
|
||||
const rows = overview?.machines ?? [];
|
||||
const [sortKey, setSortKey] = useState<SortKey>("machine");
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||
|
||||
const sortedRows = useMemo(() => {
|
||||
const factor = sortDirection === "asc" ? 1 : -1;
|
||||
return [...rows].sort((a, b) => {
|
||||
const av = metricSortValue(a, sortKey);
|
||||
const bv = metricSortValue(b, sortKey);
|
||||
if (typeof av === "number" && typeof bv === "number") {
|
||||
return (av - bv) * factor;
|
||||
}
|
||||
return String(av).localeCompare(String(bv)) * factor;
|
||||
});
|
||||
}, [rows, sortDirection, sortKey]);
|
||||
|
||||
const setSort = (key: SortKey) => {
|
||||
if (sortKey === key) {
|
||||
setSortDirection((current) => (current === "asc" ? "desc" : "asc"));
|
||||
return;
|
||||
}
|
||||
setSortKey(key);
|
||||
setSortDirection("asc");
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack spacing={1.25}>
|
||||
{!embedded ? (
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{ alignItems: "center", flexWrap: "wrap" }}
|
||||
>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
Machine monitoring
|
||||
</Typography>
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color={poller?.worker_running ? "success" : "default"}
|
||||
label={
|
||||
poller?.worker_running
|
||||
? `Poller running · ${poller.interval_seconds}s`
|
||||
: "Poller stopped"
|
||||
}
|
||||
/>
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={`Machines: ${overview?.enabled ?? 0}/${overview?.total ?? 0}`}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Last success: {formatTime(poller?.last_success_at ?? null)} · last
|
||||
run: {formatAge(poller?.last_run_at ?? null)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
) : null}
|
||||
{!embedded && poller?.last_error ? (
|
||||
<Box>
|
||||
<Typography variant="caption" color="error.main">
|
||||
Poller error: {poller.last_error}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
<TableContainer
|
||||
component={embedded ? Box : Paper}
|
||||
variant={embedded ? undefined : "outlined"}
|
||||
sx={
|
||||
embedded
|
||||
? {
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: 1,
|
||||
overflow: "hidden",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell
|
||||
sortDirection={sortKey === "machine" ? sortDirection : false}
|
||||
>
|
||||
<TableSortLabel
|
||||
active={sortKey === "machine"}
|
||||
direction={sortKey === "machine" ? sortDirection : "asc"}
|
||||
onClick={() => setSort("machine")}
|
||||
>
|
||||
Machine
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
sortDirection={sortKey === "mode" ? sortDirection : false}
|
||||
>
|
||||
<TableSortLabel
|
||||
active={sortKey === "mode"}
|
||||
direction={sortKey === "mode" ? sortDirection : "asc"}
|
||||
onClick={() => setSort("mode")}
|
||||
>
|
||||
Mode
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
sortDirection={sortKey === "status" ? sortDirection : false}
|
||||
>
|
||||
<TableSortLabel
|
||||
active={sortKey === "status"}
|
||||
direction={sortKey === "status" ? sortDirection : "asc"}
|
||||
onClick={() => setSort("status")}
|
||||
>
|
||||
Status
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
align="right"
|
||||
sortDirection={sortKey === "cpu" ? sortDirection : false}
|
||||
>
|
||||
<TableSortLabel
|
||||
active={sortKey === "cpu"}
|
||||
direction={sortKey === "cpu" ? sortDirection : "asc"}
|
||||
onClick={() => setSort("cpu")}
|
||||
>
|
||||
CPU
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
align="right"
|
||||
sortDirection={sortKey === "iowait" ? sortDirection : false}
|
||||
>
|
||||
<TableSortLabel
|
||||
active={sortKey === "iowait"}
|
||||
direction={sortKey === "iowait" ? sortDirection : "asc"}
|
||||
onClick={() => setSort("iowait")}
|
||||
>
|
||||
IO wait
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
align="right"
|
||||
sortDirection={sortKey === "mem" ? sortDirection : false}
|
||||
>
|
||||
<TableSortLabel
|
||||
active={sortKey === "mem"}
|
||||
direction={sortKey === "mem" ? sortDirection : "asc"}
|
||||
onClick={() => setSort("mem")}
|
||||
>
|
||||
RAM
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
align="right"
|
||||
sortDirection={sortKey === "net_rx" ? sortDirection : false}
|
||||
>
|
||||
<TableSortLabel
|
||||
active={sortKey === "net_rx"}
|
||||
direction={sortKey === "net_rx" ? sortDirection : "asc"}
|
||||
onClick={() => setSort("net_rx")}
|
||||
>
|
||||
Net down
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
align="right"
|
||||
sortDirection={sortKey === "net_tx" ? sortDirection : false}
|
||||
>
|
||||
<TableSortLabel
|
||||
active={sortKey === "net_tx"}
|
||||
direction={sortKey === "net_tx" ? sortDirection : "asc"}
|
||||
onClick={() => setSort("net_tx")}
|
||||
>
|
||||
Net up
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
align="right"
|
||||
sortDirection={sortKey === "disk_read" ? sortDirection : false}
|
||||
>
|
||||
<TableSortLabel
|
||||
active={sortKey === "disk_read"}
|
||||
direction={sortKey === "disk_read" ? sortDirection : "asc"}
|
||||
onClick={() => setSort("disk_read")}
|
||||
>
|
||||
Disk read
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
align="right"
|
||||
sortDirection={sortKey === "disk_write" ? sortDirection : false}
|
||||
>
|
||||
<TableSortLabel
|
||||
active={sortKey === "disk_write"}
|
||||
direction={sortKey === "disk_write" ? sortDirection : "asc"}
|
||||
onClick={() => setSort("disk_write")}
|
||||
>
|
||||
Disk write
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
align="right"
|
||||
sortDirection={sortKey === "disk_used" ? sortDirection : false}
|
||||
>
|
||||
<TableSortLabel
|
||||
active={sortKey === "disk_used"}
|
||||
direction={sortKey === "disk_used" ? sortDirection : "asc"}
|
||||
onClick={() => setSort("disk_used")}
|
||||
>
|
||||
Disk used
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
sortDirection={sortKey === "updated" ? sortDirection : false}
|
||||
>
|
||||
<TableSortLabel
|
||||
active={sortKey === "updated"}
|
||||
direction={sortKey === "updated" ? sortDirection : "asc"}
|
||||
onClick={() => setSort("updated")}
|
||||
>
|
||||
Updated
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{sortedRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={12}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No monitoring machines are configured.
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
sortedRows.map((row) => {
|
||||
const machine = row.machine;
|
||||
const status = row.status || row.status_error || "-";
|
||||
const note =
|
||||
row.metrics_error ||
|
||||
row.disk_error ||
|
||||
machine.notes ||
|
||||
row.disk?.mount ||
|
||||
"";
|
||||
const cpu = formatSummary(
|
||||
row.cpu_summary,
|
||||
(value) => `${value.toFixed(1)}%`,
|
||||
);
|
||||
const iowait = formatSummary(
|
||||
row.iowait_summary,
|
||||
(value) => `${value.toFixed(1)}%`,
|
||||
);
|
||||
const mem = formatSummary(
|
||||
row.mem_summary,
|
||||
(value) => `${value.toFixed(1)}%`,
|
||||
);
|
||||
const netDown = formatSummary(row.net_rx_summary, formatRate);
|
||||
const netUp = formatSummary(row.net_tx_summary, formatRate);
|
||||
const diskRead = formatSummary(
|
||||
row.disk_read_summary,
|
||||
formatRate,
|
||||
);
|
||||
const diskWrite = formatSummary(
|
||||
row.disk_write_summary,
|
||||
formatRate,
|
||||
);
|
||||
return (
|
||||
<TableRow key={machine.id}>
|
||||
<TableCell>
|
||||
<Stack spacing={0.25}>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={0.75}
|
||||
sx={{ alignItems: "center", flexWrap: "wrap" }}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{machine.name}
|
||||
</Typography>
|
||||
{!machine.enabled && (
|
||||
<Chip
|
||||
size="small"
|
||||
label="disabled"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{machine.mode === "local"
|
||||
? "Local API host"
|
||||
: `${machine.username || "user"}@${machine.host || "host"}:${machine.port}`}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell>{machine.mode}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color={row.status_error ? "error" : "success"}
|
||||
label={status}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{metricCell(cpu.value, cpu.subtext)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{metricCell(iowait.value, iowait.subtext)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{metricCell(mem.value, mem.subtext)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{metricCell(netDown.value, netDown.subtext)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{metricCell(netUp.value, netUp.subtext)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{metricCell(diskRead.value, diskRead.subtext)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{metricCell(diskWrite.value, diskWrite.subtext)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{row.disk
|
||||
? metricCell(
|
||||
row.disk.used_pct,
|
||||
`Used ${formatBytes(row.disk.used)}\nAvail ${formatBytes(row.disk.available)}`,
|
||||
)
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Stack spacing={0.25}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatTime(row.latest_sample?.ts ?? null)}
|
||||
</Typography>
|
||||
{note && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color={
|
||||
row.metrics_error || row.disk_error
|
||||
? "error.main"
|
||||
: "text.secondary"
|
||||
}
|
||||
>
|
||||
{note}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Box, Card, CardContent, Stack, Typography } from "@mui/material";
|
||||
|
||||
interface SectionCardProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function SectionCard({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
children,
|
||||
}: SectionCardProps) {
|
||||
return (
|
||||
<Card variant="outlined">
|
||||
<CardContent sx={{ p: 1.5 }}>
|
||||
<Stack spacing={1.25}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 1,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{description ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{description}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
{action}
|
||||
</Box>
|
||||
{children}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Box, Card, CardContent, Typography } from "@mui/material";
|
||||
|
||||
interface SelectionRailCardProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
minHeight?: number;
|
||||
contentSx?: object;
|
||||
bodySx?: object;
|
||||
}
|
||||
|
||||
export function SelectionRailCard({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
footer,
|
||||
minHeight = 420,
|
||||
contentSx,
|
||||
bodySx,
|
||||
}: SelectionRailCardProps) {
|
||||
return (
|
||||
<Card variant="outlined" sx={{ alignSelf: "start", height: "fit-content" }}>
|
||||
<CardContent
|
||||
sx={{
|
||||
p: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minHeight,
|
||||
...contentSx,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 1.25,
|
||||
borderBottom: 1,
|
||||
borderColor: "divider",
|
||||
bgcolor: "action.hover",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{ fontWeight: 800, letterSpacing: 0.2 }}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
{description ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{description}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, overflowY: "auto", ...bodySx }}>{children}</Box>
|
||||
{footer ? (
|
||||
<Box
|
||||
sx={{
|
||||
p: 1,
|
||||
borderTop: 1,
|
||||
borderColor: "divider",
|
||||
bgcolor: "background.paper",
|
||||
}}
|
||||
>
|
||||
{footer}
|
||||
</Box>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { Box, Card, CardContent, Tabs } from "@mui/material";
|
||||
|
||||
interface TabbedCardProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
tabs: ReactElement[];
|
||||
children: ReactNode;
|
||||
contentSx?: object;
|
||||
tabsSx?: object;
|
||||
}
|
||||
|
||||
export function TabbedCard({
|
||||
value,
|
||||
onChange,
|
||||
tabs,
|
||||
children,
|
||||
contentSx,
|
||||
tabsSx,
|
||||
}: TabbedCardProps) {
|
||||
return (
|
||||
<Card variant="outlined">
|
||||
<CardContent sx={{ p: 0 }}>
|
||||
<Tabs
|
||||
value={value}
|
||||
onChange={(_, next) => onChange(String(next))}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
allowScrollButtonsMobile
|
||||
sx={{ px: 1, borderBottom: 1, borderColor: "divider", ...tabsSx }}
|
||||
>
|
||||
{tabs}
|
||||
</Tabs>
|
||||
<Box sx={{ p: 1.5, ...contentSx }}>{children}</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
deleteMonitoringMachine,
|
||||
deleteSSHKey,
|
||||
fetchMonitoringSettings,
|
||||
fetchSSHKeys,
|
||||
fetchSavedTaskRuns,
|
||||
fetchSavedTasks,
|
||||
generateSSHKey,
|
||||
resetLocalDatabase,
|
||||
saveMonitoringMachine,
|
||||
saveSSHKey,
|
||||
saveTask,
|
||||
deleteTask,
|
||||
runTask,
|
||||
} from "../api/client";
|
||||
import type {
|
||||
MonitoringMachineInput,
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
export function useGenerateSSHKey() {
|
||||
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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteSSHKey() {
|
||||
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 useTaskRuns(taskId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ["tasks", taskId ?? "none", "runs"],
|
||||
queryFn: () => fetchSavedTaskRuns(taskId ?? ""),
|
||||
enabled: Boolean(taskId),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveTask() {
|
||||
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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRunTask() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
taskId,
|
||||
machineId,
|
||||
}: {
|
||||
taskId: string;
|
||||
machineId?: string;
|
||||
}) => runTask(taskId, machineId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveMonitoringMachine() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (machine: MonitoringMachineInput) =>
|
||||
saveMonitoringMachine(machine),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["monitoring"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteMonitoringMachine() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (machineId: string) => deleteMonitoringMachine(machineId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["monitoring"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,641 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Divider,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
Stack,
|
||||
Tab,
|
||||
Tabs,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import type { MonitoringMachine, SavedTaskInput } from "../types";
|
||||
import {
|
||||
useDeleteTask,
|
||||
useMonitoringSettings,
|
||||
useRunTask,
|
||||
useSaveTask,
|
||||
useTaskRuns,
|
||||
useTasks,
|
||||
} from "../hooks/useSettings";
|
||||
import { DialogFooter } from "../components/DialogFooter";
|
||||
import { HoverEditButton } from "../components/HoverEditButton";
|
||||
import { SelectionRailCard } from "../components/SelectionRailCard";
|
||||
|
||||
type ActionTab = "new" | string;
|
||||
|
||||
function emptyTask(): SavedTaskInput {
|
||||
return {
|
||||
id: null,
|
||||
name: "",
|
||||
task_type: "shell",
|
||||
content: "",
|
||||
enabled: true,
|
||||
default_machine_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_machine_id === b.default_machine_id &&
|
||||
a.notes === b.notes
|
||||
);
|
||||
}
|
||||
|
||||
function TaskEditor({
|
||||
task,
|
||||
machines,
|
||||
onChange,
|
||||
}: {
|
||||
task: SavedTaskInput;
|
||||
machines: MonitoringMachine[];
|
||||
onChange: (task: SavedTaskInput) => void;
|
||||
}) {
|
||||
const selectedMachine = machines.find(
|
||||
(machine) => machine.id === task.default_machine_id,
|
||||
);
|
||||
return (
|
||||
<Stack spacing={1.5}>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{ alignItems: "center", flexWrap: "wrap" }}
|
||||
>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{task.id ? "Edit action" : "New action"}
|
||||
</Typography>
|
||||
<Chip size="small" variant="outlined" label={task.task_type} />
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={task.enabled ? "enabled" : "disabled"}
|
||||
/>
|
||||
{selectedMachine && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={`default: ${selectedMachine.name}`}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={1.25}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Name"
|
||||
value={task.name}
|
||||
onChange={(e) => onChange({ ...task, name: e.target.value })}
|
||||
/>
|
||||
<Stack direction="row" spacing={1.25} sx={{ flexWrap: "wrap" }}>
|
||||
<FormControl size="small" sx={{ minWidth: 180, flex: "1 1 180px" }}>
|
||||
<InputLabel>Type</InputLabel>
|
||||
<Select
|
||||
label="Type"
|
||||
value={task.task_type}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...task,
|
||||
task_type: e.target.value as SavedTaskInput["task_type"],
|
||||
})
|
||||
}
|
||||
>
|
||||
<MenuItem value="shell">Shell</MenuItem>
|
||||
<MenuItem value="python">Python</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" sx={{ minWidth: 220, flex: "1 1 220px" }}>
|
||||
<InputLabel>Default machine</InputLabel>
|
||||
<Select
|
||||
label="Default machine"
|
||||
value={task.default_machine_id}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...task,
|
||||
default_machine_id: String(e.target.value),
|
||||
})
|
||||
}
|
||||
>
|
||||
<MenuItem value="">None</MenuItem>
|
||||
{machines.map((machine) => (
|
||||
<MenuItem key={machine.id} value={machine.id}>
|
||||
{machine.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Notes"
|
||||
value={task.notes}
|
||||
onChange={(e) => onChange({ ...task, notes: e.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={9}
|
||||
size="small"
|
||||
label={
|
||||
task.task_type === "python" ? "Python script" : "Shell command"
|
||||
}
|
||||
value={task.content}
|
||||
onChange={(e) => onChange({ ...task, content: e.target.value })}
|
||||
helperText={
|
||||
task.task_type === "python"
|
||||
? "Python is run as `python3 -c`."
|
||||
: "Shell commands are run through `/bin/sh -c`."
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskDialog({
|
||||
open,
|
||||
task,
|
||||
baseline,
|
||||
machines,
|
||||
onClose,
|
||||
onChange,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: {
|
||||
open: boolean;
|
||||
task: SavedTaskInput;
|
||||
baseline: SavedTaskInput;
|
||||
machines: MonitoringMachine[];
|
||||
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} onClose={requestClose} fullWidth maxWidth="md">
|
||||
<DialogTitle>{task.id ? "Edit action" : "New action"}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<TaskEditor task={task} machines={machines} onChange={onChange} />
|
||||
</DialogContent>
|
||||
<DialogFooter
|
||||
onCancel={requestClose}
|
||||
cancelLabel="Cancel"
|
||||
onConfirm={onSave}
|
||||
confirmLabel="Save action"
|
||||
confirmBusyLabel="Save action"
|
||||
secondaryAction={
|
||||
onDelete ? (
|
||||
<Button variant="outlined" color="error" onClick={onDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function Actions() {
|
||||
const { data: machines = [] } = useMonitoringSettings();
|
||||
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 [runMachineId, setRunMachineId] = useState("");
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
|
||||
const selectedTask = useMemo(
|
||||
() => tasks.find((task) => task.id === tab) ?? null,
|
||||
[tasks, tab],
|
||||
);
|
||||
const selectedRuns = useTaskRuns(selectedTask?.id);
|
||||
|
||||
const createNew = () => {
|
||||
const initial = emptyTask();
|
||||
setDraft(initial);
|
||||
setDraftBaseline(initial);
|
||||
setRunMachineId(machines[0]?.id || "");
|
||||
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_machine_id: saved.default_machine_id,
|
||||
notes: saved.notes,
|
||||
};
|
||||
setDraft(nextDraft);
|
||||
setDraftBaseline(nextDraft);
|
||||
};
|
||||
|
||||
const editingTask = selectedTask;
|
||||
|
||||
return (
|
||||
<Stack spacing={2.25}>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 800 }}>
|
||||
Actions
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Save reusable server tasks and switch between them with tabs.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip label={`${tasks.length} saved`} variant="outlined" />
|
||||
</Stack>
|
||||
|
||||
{saveTask.error && (
|
||||
<Alert severity="error">{String(saveTask.error)}</Alert>
|
||||
)}
|
||||
{deleteTask.error && (
|
||||
<Alert severity="error">{String(deleteTask.error)}</Alert>
|
||||
)}
|
||||
{runTask.error && <Alert severity="error">{String(runTask.error)}</Alert>}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", md: "280px minmax(0, 1fr)" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<SelectionRailCard
|
||||
title="Saved actions"
|
||||
description="Pick a saved task, then edit or run it from the detail pane."
|
||||
contentSx={{ maxHeight: { xs: 520, md: 620 } }}
|
||||
footer={
|
||||
<Button fullWidth variant="contained" onClick={createNew}>
|
||||
+ New action
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(_, value) => setTab(value)}
|
||||
orientation="vertical"
|
||||
variant="scrollable"
|
||||
sx={{ borderRight: 1, borderColor: "divider" }}
|
||||
>
|
||||
{tasks.map((task) => (
|
||||
<Box
|
||||
key={task.id}
|
||||
sx={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
"&:hover .rail-edit": { opacity: 1 },
|
||||
}}
|
||||
>
|
||||
<Tab
|
||||
value={task.id}
|
||||
label={task.name}
|
||||
sx={{
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "flex-start",
|
||||
width: 1,
|
||||
pr: 5,
|
||||
}}
|
||||
onClick={() => setTab(task.id)}
|
||||
onDoubleClick={() => {
|
||||
const initial = {
|
||||
id: task.id,
|
||||
name: task.name,
|
||||
task_type: task.task_type,
|
||||
content: task.content,
|
||||
enabled: task.enabled,
|
||||
default_machine_id: task.default_machine_id,
|
||||
notes: task.notes,
|
||||
};
|
||||
setDraft(initial);
|
||||
setDraftBaseline(initial);
|
||||
setEditOpen(true);
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
right: 4,
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
}}
|
||||
>
|
||||
<HoverEditButton
|
||||
onClick={() => {
|
||||
const initial = {
|
||||
id: task.id,
|
||||
name: task.name,
|
||||
task_type: task.task_type,
|
||||
content: task.content,
|
||||
enabled: task.enabled,
|
||||
default_machine_id: task.default_machine_id,
|
||||
notes: task.notes,
|
||||
};
|
||||
setDraft(initial);
|
||||
setDraftBaseline(initial);
|
||||
setEditOpen(true);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Tabs>
|
||||
</SelectionRailCard>
|
||||
|
||||
<Stack spacing={2}>
|
||||
{editingTask ? (
|
||||
<Card variant="outlined">
|
||||
<CardContent sx={{ p: 1.5 }}>
|
||||
<Stack spacing={1.5}>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{editingTask.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Open the editor popup to modify this action.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{ flexWrap: "wrap" }}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
const initial = {
|
||||
id: editingTask.id,
|
||||
name: editingTask.name,
|
||||
task_type: editingTask.task_type,
|
||||
content: editingTask.content,
|
||||
enabled: editingTask.enabled,
|
||||
default_machine_id: editingTask.default_machine_id,
|
||||
notes: editingTask.notes,
|
||||
};
|
||||
setDraft(initial);
|
||||
setDraftBaseline(initial);
|
||||
setEditOpen(true);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={runTask.isPending || !runMachineId}
|
||||
onClick={async () => {
|
||||
await runTask.mutateAsync({
|
||||
taskId: editingTask.id,
|
||||
machineId: runMachineId,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{runTask.isPending ? "Running..." : "Run action"}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{ alignItems: "center", flexWrap: "wrap" }}
|
||||
>
|
||||
<FormControl size="small" sx={{ minWidth: 240 }}>
|
||||
<InputLabel>Run on machine</InputLabel>
|
||||
<Select
|
||||
label="Run on machine"
|
||||
value={runMachineId}
|
||||
onChange={(e) =>
|
||||
setRunMachineId(String(e.target.value))
|
||||
}
|
||||
>
|
||||
{machines.map((machine) => (
|
||||
<MenuItem key={machine.id} value={machine.id}>
|
||||
{machine.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
Recent runs
|
||||
</Typography>
|
||||
{selectedRuns.data?.items?.length ? (
|
||||
<Stack spacing={1.25}>
|
||||
{selectedRuns.data.items.map((run) => (
|
||||
<Card key={run.id} variant="outlined">
|
||||
<CardContent sx={{ p: 1.5 }}>
|
||||
<Stack spacing={1}>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{ alignItems: "center", flexWrap: "wrap" }}
|
||||
>
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={run.status}
|
||||
/>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
>
|
||||
{run.machine_name} ·{" "}
|
||||
{new Date(
|
||||
run.created_at * 1000,
|
||||
).toLocaleString()}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{run.stdout_tail && (
|
||||
<Box
|
||||
sx={{
|
||||
px: 1,
|
||||
py: 0.75,
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
>
|
||||
stdout
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontFamily: "monospace",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{run.stdout_tail}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{run.stderr_tail && (
|
||||
<Box
|
||||
sx={{
|
||||
px: 1,
|
||||
py: 0.75,
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
>
|
||||
stderr
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontFamily: "monospace",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{run.stderr_tail}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{run.error && (
|
||||
<Alert severity="error">{run.error}</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Alert severity="info">No runs yet.</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
<Card variant="outlined">
|
||||
<CardContent sx={{ p: 2 }}>
|
||||
<Stack spacing={1.25}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
No action selected
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Select a saved action from the list on the left to view
|
||||
its details, run it, or open the editor popup. Use the
|
||||
button at the bottom to add a new action.
|
||||
</Typography>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{ flexWrap: "wrap" }}
|
||||
>
|
||||
<Button variant="contained" onClick={createNew}>
|
||||
+ New action
|
||||
</Button>
|
||||
{tasks[0] && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setTab(tasks[0].id)}
|
||||
>
|
||||
Select first action
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card variant="outlined">
|
||||
<CardContent sx={{ p: 2 }}>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
What this panel shows
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Saved actions stay on the left rail, while details, run
|
||||
controls, and recent history appear here.
|
||||
</Typography>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<TaskDialog
|
||||
open={editOpen}
|
||||
task={draft}
|
||||
baseline={draftBaseline}
|
||||
machines={machines}
|
||||
onClose={() => setEditOpen(false)}
|
||||
onChange={setDraft}
|
||||
onSave={saveDraft}
|
||||
onDelete={
|
||||
draft.id ? () => deleteTask.mutate(String(draft.id)) : undefined
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
Grid,
|
||||
Stack,
|
||||
Tab,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Media } from "./Media";
|
||||
import { useCounts, useLibraries } from "../hooks/useDashboard";
|
||||
import { useMonitoringSettings } from "../hooks/useSettings";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { TabbedCard } from "../components/TabbedCard";
|
||||
|
||||
function JellyfinLibraryStats() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { data: machines = [] } = useMonitoringSettings();
|
||||
const jellyfinMachines = useMemo(
|
||||
() =>
|
||||
machines.filter(
|
||||
(machine) => machine.enabled && machine.services.includes("jellyfin"),
|
||||
),
|
||||
[machines],
|
||||
);
|
||||
const selectedMachineId =
|
||||
searchParams.get("machine_id") || jellyfinMachines[0]?.id || "";
|
||||
const { data: counts } = useCounts(selectedMachineId || undefined);
|
||||
const { data: libraries } = useLibraries(selectedMachineId || undefined);
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title="Library stats"
|
||||
description="Compact Jellyfin summary for the selected machine."
|
||||
action={
|
||||
<Chip
|
||||
label={selectedMachineId ? "Selected machine" : "Default machine"}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Stack spacing={1.25}>
|
||||
{counts ? (
|
||||
<Grid container spacing={1}>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<Card variant="outlined">
|
||||
<CardContent sx={{ py: 1.25, px: 1.5, textAlign: "center" }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Total
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{ fontWeight: 800, lineHeight: 1.1 }}
|
||||
>
|
||||
{(
|
||||
counts.movies +
|
||||
counts.series +
|
||||
counts.episodes
|
||||
).toLocaleString()}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<Card variant="outlined">
|
||||
<CardContent sx={{ py: 1.25, px: 1.5, textAlign: "center" }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Movies
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{ fontWeight: 800, lineHeight: 1.1 }}
|
||||
>
|
||||
{counts.movies.toLocaleString()}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<Card variant="outlined">
|
||||
<CardContent sx={{ py: 1.25, px: 1.5, textAlign: "center" }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Series
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{ fontWeight: 800, lineHeight: 1.1 }}
|
||||
>
|
||||
{counts.series.toLocaleString()}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<Card variant="outlined">
|
||||
<CardContent sx={{ py: 1.25, px: 1.5, textAlign: "center" }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Episodes
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{ fontWeight: 800, lineHeight: 1.1 }}
|
||||
>
|
||||
{counts.episodes.toLocaleString()}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
) : null}
|
||||
|
||||
{libraries?.length ? (
|
||||
<Grid container spacing={1}>
|
||||
{libraries.map((library) => (
|
||||
<Grid key={library.library} size={{ xs: 12, md: 6 }}>
|
||||
<Card variant="outlined">
|
||||
<CardContent sx={{ py: 1.1, px: 1.5 }}>
|
||||
<Stack spacing={0.5}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{ fontWeight: 700 }}
|
||||
noWrap
|
||||
>
|
||||
{library.library}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Total {library.total.toLocaleString()} · Movies{" "}
|
||||
{library.movies.toLocaleString()} · Series{" "}
|
||||
{library.series.toLocaleString()}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
) : null}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function Applications() {
|
||||
const [tab, setTab] = useState("jellyfin");
|
||||
|
||||
return (
|
||||
<Stack spacing={2.25}>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 800 }}>
|
||||
Applications
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Browse application-specific tools from a compact tabbed workspace.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<TabbedCard
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
tabs={[
|
||||
<Tab key="jellyfin" value="jellyfin" label="Jellyfin" />,
|
||||
<Tab key="nextcloud" value="nextcloud" label="Nextcloud" />,
|
||||
]}
|
||||
>
|
||||
{tab === "jellyfin" ? (
|
||||
<Stack spacing={2}>
|
||||
<JellyfinLibraryStats />
|
||||
<Media />
|
||||
</Stack>
|
||||
) : (
|
||||
<Card variant="outlined">
|
||||
<CardContent sx={{ p: 1.5 }}>
|
||||
<Alert severity="info">
|
||||
Nextcloud support will be added in a future update.
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabbedCard>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user