Show live feedback for remote request actions
This commit is contained in:
@@ -4,6 +4,39 @@ import time
|
||||
import httpx
|
||||
|
||||
from ..logging_config import sanitize_headers, sanitize_value
|
||||
from ..services.operation_progress import finish_remote_call, start_remote_call
|
||||
|
||||
|
||||
_SERVICE_NAMES = {
|
||||
"JellyseerrClient": "Seerr",
|
||||
"SonarrClient": "Sonarr",
|
||||
"RadarrClient": "Radarr",
|
||||
"ProwlarrClient": "Prowlarr",
|
||||
"JellyfinClient": "Jellyfin",
|
||||
"QBittorrentClient": "qBittorrent",
|
||||
}
|
||||
|
||||
|
||||
def _operation_messages(service: str, method: str, path: str) -> tuple[str, str]:
|
||||
normalized_path = path.lower()
|
||||
normalized_method = method.upper()
|
||||
if service == "Seerr" and "/request/" in normalized_path and normalized_method == "GET":
|
||||
return "Reading the request from Seerr…", "Seerr returned the current request record"
|
||||
if service == "Radarr" and normalized_path.endswith("/movie") and normalized_method == "GET":
|
||||
return "Checking Radarr for the movie…", "Radarr returned the movie record"
|
||||
if service == "Sonarr" and normalized_path.endswith("/series") and normalized_method == "GET":
|
||||
return "Checking Sonarr for the series…", "Sonarr returned the series record"
|
||||
if service in {"Radarr", "Sonarr"} and "/queue" in normalized_path:
|
||||
return f"Checking {service}'s download queue…", f"{service} returned its queue state"
|
||||
if service == "Sonarr" and "/episode" in normalized_path:
|
||||
return "Checking episode availability in Sonarr…", "Sonarr returned episode availability"
|
||||
if service in {"Radarr", "Sonarr"} and "/release" in normalized_path:
|
||||
return f"Checking releases through {service}…", f"{service} returned release information"
|
||||
if service in {"Radarr", "Sonarr"} and "/command" in normalized_path:
|
||||
return f"Sending a command to {service}…", f"{service} accepted the command"
|
||||
if service == "Prowlarr" and "/health" in normalized_path:
|
||||
return "Checking Prowlarr indexer health…", "Prowlarr returned its indexer health"
|
||||
return f"Contacting {service}…", f"{service} responded"
|
||||
|
||||
|
||||
class ApiClient:
|
||||
@@ -43,6 +76,9 @@ class ApiClient:
|
||||
return None
|
||||
url = f"{self.base_url}{path}"
|
||||
started_at = time.perf_counter()
|
||||
service_name = _SERVICE_NAMES.get(self.__class__.__name__, self.__class__.__name__.removesuffix("Client"))
|
||||
active_message, complete_message = _operation_messages(service_name, method, path)
|
||||
operation_event_id = start_remote_call(service_name, active_message)
|
||||
self.logger.debug(
|
||||
"outbound request started method=%s url=%s params=%s payload=%s headers=%s",
|
||||
method,
|
||||
@@ -69,9 +105,14 @@ class ApiClient:
|
||||
response.status_code,
|
||||
duration_ms,
|
||||
)
|
||||
if not response.content:
|
||||
return None
|
||||
return response.json()
|
||||
result = response.json() if response.content else None
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=True,
|
||||
status_code=response.status_code,
|
||||
message=f"{complete_message} in {duration_ms / 1000:.1f}s.",
|
||||
)
|
||||
return result
|
||||
except httpx.HTTPStatusError as exc:
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
response = exc.response
|
||||
@@ -85,6 +126,12 @@ class ApiClient:
|
||||
duration_ms,
|
||||
self._response_summary(response),
|
||||
)
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=False,
|
||||
status_code=status if isinstance(status, int) else None,
|
||||
message=f"{service_name} returned HTTP {status} after {duration_ms / 1000:.1f}s.",
|
||||
)
|
||||
raise
|
||||
except Exception:
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
@@ -94,6 +141,11 @@ class ApiClient:
|
||||
url,
|
||||
duration_ms,
|
||||
)
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=False,
|
||||
message=f"Magent could not get a response from {service_name} after {duration_ms / 1000:.1f}s.",
|
||||
)
|
||||
raise
|
||||
|
||||
async def get(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from typing import Any, Dict, Optional
|
||||
import httpx
|
||||
import time
|
||||
from .base import ApiClient
|
||||
from ..services.operation_progress import finish_remote_call, start_remote_call
|
||||
|
||||
|
||||
class JellyfinClient(ApiClient):
|
||||
@@ -167,6 +169,8 @@ class JellyfinClient(ApiClient):
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not self.base_url or not self.api_key:
|
||||
return None
|
||||
started_at = time.perf_counter()
|
||||
operation_event_id = start_remote_call("Jellyfin", "Checking whether the title is available in Jellyfin…")
|
||||
url = f"{self.base_url}/Items"
|
||||
params = {
|
||||
"SearchTerm": term,
|
||||
@@ -175,10 +179,29 @@ class JellyfinClient(ApiClient):
|
||||
"Limit": limit,
|
||||
}
|
||||
headers = self._emby_headers()
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=True,
|
||||
status_code=response.status_code,
|
||||
message=f"Jellyfin returned library availability in {duration_ms / 1000:.1f}s.",
|
||||
)
|
||||
return result
|
||||
except Exception as exc:
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=False,
|
||||
status_code=status_code,
|
||||
message=f"Jellyfin returned an error after {duration_ms / 1000:.1f}s.",
|
||||
)
|
||||
raise
|
||||
|
||||
async def get_system_info(self) -> Optional[Dict[str, Any]]:
|
||||
if not self.base_url or not self.api_key:
|
||||
@@ -193,9 +216,29 @@ class JellyfinClient(ApiClient):
|
||||
async def refresh_library(self, recursive: bool = True) -> None:
|
||||
if not self.base_url or not self.api_key:
|
||||
return None
|
||||
started_at = time.perf_counter()
|
||||
operation_event_id = start_remote_call("Jellyfin", "Asking Jellyfin to refresh its library…")
|
||||
url = f"{self.base_url}/Library/Refresh"
|
||||
headers = self._emby_headers()
|
||||
params = {"Recursive": "true" if recursive else "false"}
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(url, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(url, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=True,
|
||||
status_code=response.status_code,
|
||||
message=f"Jellyfin accepted the library refresh in {duration_ms / 1000:.1f}s.",
|
||||
)
|
||||
except Exception as exc:
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=False,
|
||||
status_code=status_code,
|
||||
message=f"Jellyfin returned an error after {duration_ms / 1000:.1f}s.",
|
||||
)
|
||||
raise
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from typing import Any, Dict, Optional
|
||||
import httpx
|
||||
import logging
|
||||
import time
|
||||
from .base import ApiClient
|
||||
from ..services.operation_progress import finish_remote_call, start_remote_call
|
||||
|
||||
|
||||
class QBittorrentClient(ApiClient):
|
||||
@@ -31,28 +33,90 @@ class QBittorrentClient(ApiClient):
|
||||
async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
||||
if not self.base_url:
|
||||
return None
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
await self._login(client)
|
||||
response = await client.get(f"{self.base_url}{path}", params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
started_at = time.perf_counter()
|
||||
operation_event_id = start_remote_call("qBittorrent", "Checking qBittorrent for matching downloads…")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
await self._login(client)
|
||||
response = await client.get(f"{self.base_url}{path}", params=params)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=True,
|
||||
status_code=response.status_code,
|
||||
message=f"qBittorrent returned the current download state in {duration_ms / 1000:.1f}s.",
|
||||
)
|
||||
return result
|
||||
except Exception as exc:
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=False,
|
||||
status_code=status_code,
|
||||
message=f"qBittorrent returned an error after {duration_ms / 1000:.1f}s.",
|
||||
)
|
||||
raise
|
||||
|
||||
async def _get_text(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
||||
if not self.base_url:
|
||||
return None
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
await self._login(client)
|
||||
response = await client.get(f"{self.base_url}{path}", params=params)
|
||||
response.raise_for_status()
|
||||
return response.text.strip()
|
||||
started_at = time.perf_counter()
|
||||
operation_event_id = start_remote_call("qBittorrent")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
await self._login(client)
|
||||
response = await client.get(f"{self.base_url}{path}", params=params)
|
||||
response.raise_for_status()
|
||||
result = response.text.strip()
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=True,
|
||||
status_code=response.status_code,
|
||||
message=f"qBittorrent responded in {duration_ms / 1000:.1f}s.",
|
||||
)
|
||||
return result
|
||||
except Exception as exc:
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=False,
|
||||
status_code=status_code,
|
||||
message=f"qBittorrent returned an error after {duration_ms / 1000:.1f}s.",
|
||||
)
|
||||
raise
|
||||
|
||||
async def _post_form(self, path: str, data: Dict[str, Any]) -> None:
|
||||
if not self.base_url:
|
||||
return None
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
await self._login(client)
|
||||
response = await client.post(f"{self.base_url}{path}", data=data)
|
||||
response.raise_for_status()
|
||||
started_at = time.perf_counter()
|
||||
operation_event_id = start_remote_call("qBittorrent")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
await self._login(client)
|
||||
response = await client.post(f"{self.base_url}{path}", data=data)
|
||||
response.raise_for_status()
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=True,
|
||||
status_code=response.status_code,
|
||||
message=f"qBittorrent accepted the action in {duration_ms / 1000:.1f}s.",
|
||||
)
|
||||
except Exception as exc:
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=False,
|
||||
status_code=status_code,
|
||||
message=f"qBittorrent returned an error after {duration_ms / 1000:.1f}s.",
|
||||
)
|
||||
raise
|
||||
|
||||
async def is_webui_reachable(self) -> bool:
|
||||
if not self.base_url:
|
||||
|
||||
@@ -25,7 +25,14 @@ from .routers.feedback import router as feedback_router
|
||||
from .routers.site import router as site_router
|
||||
from .routers.events import router as events_router
|
||||
from .routers.portal import router as portal_router
|
||||
from .routers.operations import router as operations_router
|
||||
from .services.jellyfin_sync import run_daily_jellyfin_sync
|
||||
from .services.operation_progress import (
|
||||
begin_operation,
|
||||
finish_operation,
|
||||
normalize_operation_id,
|
||||
reset_operation,
|
||||
)
|
||||
from .logging_config import (
|
||||
bind_request_id,
|
||||
configure_logging,
|
||||
@@ -59,6 +66,14 @@ app.add_middleware(
|
||||
async def log_requests_and_add_security_headers(request: Request, call_next):
|
||||
request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex[:12]
|
||||
token = bind_request_id(request_id)
|
||||
operation_id = normalize_operation_id(request.headers.get("X-Magent-Operation-ID"))
|
||||
operation_token = None
|
||||
if operation_id and request.method.upper() not in {"GET", "HEAD", "OPTIONS"}:
|
||||
operation_token = begin_operation(
|
||||
operation_id,
|
||||
label=request.headers.get("X-Magent-Operation-Label"),
|
||||
path=request.url.path,
|
||||
)
|
||||
request.state.request_id = request_id
|
||||
started_at = time.perf_counter()
|
||||
body = await request.body()
|
||||
@@ -101,6 +116,9 @@ async def log_requests_and_add_security_headers(request: Request, call_next):
|
||||
request.url.path,
|
||||
duration_ms,
|
||||
)
|
||||
if operation_id and operation_token is not None:
|
||||
finish_operation(operation_id, success=False, status_code=500)
|
||||
reset_operation(operation_token)
|
||||
reset_request_id(token)
|
||||
raise
|
||||
|
||||
@@ -130,6 +148,13 @@ async def log_requests_and_add_security_headers(request: Request, call_next):
|
||||
}
|
||||
),
|
||||
)
|
||||
if operation_id and operation_token is not None:
|
||||
finish_operation(
|
||||
operation_id,
|
||||
success=response.status_code < 400,
|
||||
status_code=response.status_code,
|
||||
)
|
||||
reset_operation(operation_token)
|
||||
reset_request_id(token)
|
||||
return response
|
||||
|
||||
@@ -244,3 +269,4 @@ app.include_router(feedback_router)
|
||||
app.include_router(site_router)
|
||||
app.include_router(events_router)
|
||||
app.include_router(portal_router)
|
||||
app.include_router(operations_router)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..services.operation_progress import get_operation
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/operations",
|
||||
tags=["operations"],
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{operation_id}")
|
||||
async def operation_status(operation_id: str) -> dict:
|
||||
operation = get_operation(operation_id)
|
||||
if not operation:
|
||||
raise HTTPException(status_code=404, detail="Operation not found")
|
||||
return operation
|
||||
@@ -0,0 +1,206 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextvars import ContextVar, Token
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timezone
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
_OPERATION_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{8,80}$")
|
||||
_OPERATION_TTL_SECONDS = 15 * 60
|
||||
_MAX_OPERATIONS = 500
|
||||
_MAX_EVENTS = 60
|
||||
_current_operation_id: ContextVar[Optional[str]] = ContextVar(
|
||||
"magent_operation_id", default=None
|
||||
)
|
||||
_operations: Dict[str, Dict[str, Any]] = {}
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def normalize_operation_id(value: Optional[str]) -> Optional[str]:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
normalized = value.strip()
|
||||
return normalized if _OPERATION_ID_PATTERN.fullmatch(normalized) else None
|
||||
|
||||
|
||||
def _prune_locked(now_monotonic: float) -> None:
|
||||
expired = [
|
||||
operation_id
|
||||
for operation_id, operation in _operations.items()
|
||||
if now_monotonic - float(operation.get("updated_monotonic") or 0) > _OPERATION_TTL_SECONDS
|
||||
]
|
||||
for operation_id in expired:
|
||||
_operations.pop(operation_id, None)
|
||||
if len(_operations) <= _MAX_OPERATIONS:
|
||||
return
|
||||
oldest = sorted(
|
||||
_operations,
|
||||
key=lambda operation_id: float(_operations[operation_id].get("updated_monotonic") or 0),
|
||||
)
|
||||
for operation_id in oldest[: len(_operations) - _MAX_OPERATIONS]:
|
||||
_operations.pop(operation_id, None)
|
||||
|
||||
|
||||
def begin_operation(operation_id: str, *, label: Optional[str], path: str) -> Token:
|
||||
now_monotonic = time.monotonic()
|
||||
now_iso = _now_iso()
|
||||
normalized_label = str(label or "Requested action").strip()[:120] or "Requested action"
|
||||
with _lock:
|
||||
_prune_locked(now_monotonic)
|
||||
_operations[operation_id] = {
|
||||
"id": operation_id,
|
||||
"label": normalized_label,
|
||||
"path": path,
|
||||
"status": "running",
|
||||
"started_at": now_iso,
|
||||
"updated_at": now_iso,
|
||||
"updated_monotonic": now_monotonic,
|
||||
"duration_ms": None,
|
||||
"events": [
|
||||
{
|
||||
"id": uuid.uuid4().hex,
|
||||
"service": "Magent",
|
||||
"state": "complete",
|
||||
"message": "Magent received the action.",
|
||||
"started_at": now_iso,
|
||||
"finished_at": now_iso,
|
||||
"duration_ms": 0,
|
||||
"status_code": None,
|
||||
}
|
||||
],
|
||||
}
|
||||
return _current_operation_id.set(operation_id)
|
||||
|
||||
|
||||
def reset_operation(token: Token) -> None:
|
||||
_current_operation_id.reset(token)
|
||||
|
||||
|
||||
def start_remote_call(service: str, message: Optional[str] = None) -> Optional[str]:
|
||||
operation_id = _current_operation_id.get()
|
||||
if not operation_id:
|
||||
return None
|
||||
event_id = uuid.uuid4().hex
|
||||
now_iso = _now_iso()
|
||||
now_monotonic = time.monotonic()
|
||||
with _lock:
|
||||
operation = _operations.get(operation_id)
|
||||
if not operation:
|
||||
return None
|
||||
operation["events"].append(
|
||||
{
|
||||
"id": event_id,
|
||||
"service": service,
|
||||
"state": "active",
|
||||
"message": message or f"Contacting {service}…",
|
||||
"started_at": now_iso,
|
||||
"finished_at": None,
|
||||
"duration_ms": None,
|
||||
"status_code": None,
|
||||
"started_monotonic": now_monotonic,
|
||||
}
|
||||
)
|
||||
operation["events"] = operation["events"][-_MAX_EVENTS:]
|
||||
operation["updated_at"] = now_iso
|
||||
operation["updated_monotonic"] = now_monotonic
|
||||
return event_id
|
||||
|
||||
|
||||
def finish_remote_call(
|
||||
event_id: Optional[str],
|
||||
*,
|
||||
success: bool,
|
||||
status_code: Optional[int] = None,
|
||||
message: Optional[str] = None,
|
||||
) -> None:
|
||||
operation_id = _current_operation_id.get()
|
||||
if not operation_id or not event_id:
|
||||
return
|
||||
now_iso = _now_iso()
|
||||
now_monotonic = time.monotonic()
|
||||
with _lock:
|
||||
operation = _operations.get(operation_id)
|
||||
if not operation:
|
||||
return
|
||||
event = next(
|
||||
(candidate for candidate in operation["events"] if candidate.get("id") == event_id),
|
||||
None,
|
||||
)
|
||||
if not event:
|
||||
return
|
||||
started_monotonic = float(event.pop("started_monotonic", now_monotonic))
|
||||
event["state"] = "complete" if success else "error"
|
||||
event["finished_at"] = now_iso
|
||||
event["duration_ms"] = round((now_monotonic - started_monotonic) * 1000, 1)
|
||||
event["status_code"] = status_code
|
||||
event["message"] = message or (
|
||||
f"{event['service']} responded successfully."
|
||||
if success
|
||||
else f"{event['service']} returned an error."
|
||||
)
|
||||
operation["updated_at"] = now_iso
|
||||
operation["updated_monotonic"] = now_monotonic
|
||||
|
||||
|
||||
def finish_operation(operation_id: str, *, success: bool, status_code: Optional[int]) -> None:
|
||||
now_iso = _now_iso()
|
||||
now_monotonic = time.monotonic()
|
||||
with _lock:
|
||||
operation = _operations.get(operation_id)
|
||||
if not operation:
|
||||
return
|
||||
for event in operation["events"]:
|
||||
if event.get("state") == "active":
|
||||
started_monotonic = float(event.pop("started_monotonic", now_monotonic))
|
||||
event["state"] = "error"
|
||||
event["finished_at"] = now_iso
|
||||
event["duration_ms"] = round((now_monotonic - started_monotonic) * 1000, 1)
|
||||
event["message"] = f"{event.get('service') or 'Remote service'} did not complete."
|
||||
started = datetime.fromisoformat(str(operation["started_at"]))
|
||||
duration_ms = (datetime.now(timezone.utc) - started).total_seconds() * 1000
|
||||
operation["status"] = "complete" if success else "error"
|
||||
operation["status_code"] = status_code
|
||||
operation["duration_ms"] = round(duration_ms, 1)
|
||||
operation["updated_at"] = now_iso
|
||||
operation["updated_monotonic"] = now_monotonic
|
||||
operation["events"].append(
|
||||
{
|
||||
"id": uuid.uuid4().hex,
|
||||
"service": "Magent",
|
||||
"state": "complete" if success else "error",
|
||||
"message": (
|
||||
"Magent finished processing the action."
|
||||
if success
|
||||
else "Magent could not complete the action."
|
||||
),
|
||||
"started_at": now_iso,
|
||||
"finished_at": now_iso,
|
||||
"duration_ms": 0,
|
||||
"status_code": status_code,
|
||||
}
|
||||
)
|
||||
operation["events"] = operation["events"][-_MAX_EVENTS:]
|
||||
|
||||
|
||||
def get_operation(operation_id: str) -> Optional[Dict[str, Any]]:
|
||||
normalized = normalize_operation_id(operation_id)
|
||||
if not normalized:
|
||||
return None
|
||||
with _lock:
|
||||
operation = _operations.get(normalized)
|
||||
if not operation:
|
||||
return None
|
||||
result = deepcopy(operation)
|
||||
result.pop("updated_monotonic", None)
|
||||
for event in result.get("events", []):
|
||||
event.pop("started_monotonic", None)
|
||||
return result
|
||||
@@ -20,6 +20,14 @@ from backend.app.routers import site as site_router
|
||||
from backend.app.routers import status as status_router
|
||||
from backend.app.security import PASSWORD_POLICY_MESSAGE, validate_password_policy
|
||||
from backend.app.services import password_reset
|
||||
from backend.app.services.operation_progress import (
|
||||
begin_operation,
|
||||
finish_operation,
|
||||
finish_remote_call,
|
||||
get_operation,
|
||||
reset_operation,
|
||||
start_remote_call,
|
||||
)
|
||||
from backend.app.services.snapshot import _build_presentation, _episode_availability, _torrent_progress
|
||||
|
||||
|
||||
@@ -142,6 +150,38 @@ class ServiceStatusTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertIn("credentials", result["message"].lower())
|
||||
|
||||
|
||||
class OperationProgressTests(unittest.TestCase):
|
||||
def test_remote_interaction_is_visible_until_operation_completes(self) -> None:
|
||||
operation_id = "operation-progress-test"
|
||||
token = begin_operation(
|
||||
operation_id,
|
||||
label="Recheck request status",
|
||||
path="/requests/3914/actions/recheck",
|
||||
)
|
||||
try:
|
||||
event_id = start_remote_call("Radarr")
|
||||
active = get_operation(operation_id)
|
||||
self.assertEqual(active["status"], "running")
|
||||
self.assertEqual(active["events"][-1]["service"], "Radarr")
|
||||
self.assertEqual(active["events"][-1]["state"], "active")
|
||||
|
||||
finish_remote_call(
|
||||
event_id,
|
||||
success=True,
|
||||
status_code=200,
|
||||
message="Radarr responded in 0.2s.",
|
||||
)
|
||||
finish_operation(operation_id, success=True, status_code=200)
|
||||
finally:
|
||||
reset_operation(token)
|
||||
|
||||
completed = get_operation(operation_id)
|
||||
self.assertEqual(completed["status"], "complete")
|
||||
self.assertEqual(completed["events"][-2]["state"], "complete")
|
||||
self.assertEqual(completed["events"][-2]["status_code"], 200)
|
||||
self.assertEqual(completed["events"][-1]["service"], "Magent")
|
||||
|
||||
|
||||
class SiteInfoTests(unittest.TestCase):
|
||||
def test_site_public_exposes_requests_navigation_toggle(self) -> None:
|
||||
runtime = SimpleNamespace(
|
||||
|
||||
@@ -2012,6 +2012,76 @@ button:disabled,
|
||||
}
|
||||
.request-action-feedback.is-error { color: var(--request-red); background: rgba(255, 86, 113, 0.08); }
|
||||
|
||||
.request-operation-progress {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 16px 18px;
|
||||
border-top: 1px solid var(--ops-line-soft);
|
||||
background: rgba(5, 9, 20, 0.42);
|
||||
}
|
||||
.request-operation-progress.is-running { background: rgba(14, 165, 233, 0.055); }
|
||||
.request-operation-progress.is-error { background: rgba(255, 86, 113, 0.055); }
|
||||
.request-operation-heading,
|
||||
.request-operation-heading-actions,
|
||||
.request-operation-event { display: flex; align-items: center; gap: 12px; }
|
||||
.request-operation-heading { justify-content: space-between; }
|
||||
.request-operation-heading > div:first-child { display: grid; gap: 4px; }
|
||||
.request-operation-heading > div:first-child > strong { color: var(--ops-text); font-size: 0.94rem; }
|
||||
.request-operation-heading-actions { color: var(--ops-muted); font-size: 0.7rem; }
|
||||
.request-operation-heading-actions button {
|
||||
min-height: 30px;
|
||||
padding: 5px 9px;
|
||||
border: 1px solid var(--ops-line);
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
color: var(--ops-text);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
.request-operation-status {
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--ops-line);
|
||||
border-radius: 999px;
|
||||
color: var(--ops-muted);
|
||||
font-family: "JetBrains Mono", Consolas, monospace;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 750;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.request-operation-status.is-running { border-color: rgba(126, 215, 255, 0.36); color: var(--ops-cyan); }
|
||||
.request-operation-status.is-complete { border-color: rgba(72, 224, 178, 0.3); color: var(--request-green); }
|
||||
.request-operation-status.is-error { border-color: rgba(255, 86, 113, 0.34); color: var(--request-red); }
|
||||
.request-operation-events { display: grid; gap: 7px; }
|
||||
.request-operation-event {
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--ops-line-soft);
|
||||
border-radius: var(--ops-radius);
|
||||
background: rgba(255, 255, 255, 0.022);
|
||||
}
|
||||
.request-operation-event > i {
|
||||
flex: 0 0 auto;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border: 2px solid currentColor;
|
||||
border-radius: 50%;
|
||||
color: var(--ops-muted);
|
||||
}
|
||||
.request-operation-event.is-active > i {
|
||||
color: var(--ops-cyan);
|
||||
box-shadow: 0 0 12px currentColor;
|
||||
animation: request-operation-pulse 1.15s ease-in-out infinite;
|
||||
}
|
||||
.request-operation-event.is-complete > i { color: var(--request-green); background: currentColor; }
|
||||
.request-operation-event.is-error > i { color: var(--request-red); background: currentColor; }
|
||||
.request-operation-event > div { display: grid; gap: 2px; min-width: 0; }
|
||||
.request-operation-event strong { color: var(--ops-text); font-size: 0.75rem; }
|
||||
.request-operation-event span { color: var(--ops-muted); font-size: 0.75rem; }
|
||||
.request-operation-event small { margin-left: auto; color: var(--ops-muted); font-size: 0.66rem; white-space: nowrap; }
|
||||
@keyframes request-operation-pulse {
|
||||
0%, 100% { opacity: 0.5; transform: scale(0.8); }
|
||||
50% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.request-journey { display: grid; gap: 18px; padding: 20px; }
|
||||
.request-journey-heading,
|
||||
.request-release-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
|
||||
@@ -2138,4 +2208,7 @@ button:disabled,
|
||||
.request-stage-actions { display: grid; }
|
||||
.request-action-row button,
|
||||
.request-release button { width: 100%; }
|
||||
.request-operation-heading { align-items: flex-start; flex-direction: column; }
|
||||
.request-operation-heading-actions { width: 100%; flex-wrap: wrap; }
|
||||
.request-operation-event { align-items: flex-start; }
|
||||
}
|
||||
|
||||
@@ -101,6 +101,23 @@ type LiveDownloadProgress = {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
type OperationEvent = {
|
||||
id: string
|
||||
service: string
|
||||
state: 'active' | 'complete' | 'error' | string
|
||||
message: string
|
||||
duration_ms?: number | null
|
||||
status_code?: number | null
|
||||
}
|
||||
|
||||
type OperationProgress = {
|
||||
id: string
|
||||
label: string
|
||||
status: 'running' | 'complete' | 'error' | string
|
||||
duration_ms?: number | null
|
||||
events: OperationEvent[]
|
||||
}
|
||||
|
||||
const readApiError = async (response: Response, fallback: string) => {
|
||||
try {
|
||||
const contentType = response.headers.get('content-type') ?? ''
|
||||
@@ -153,6 +170,12 @@ const torrentProgress = (torrent: Record<string, any>) => {
|
||||
|
||||
const formatProgress = (progress: number) => `${progress.toFixed(1).replace(/\.0$/, '')}% complete`
|
||||
|
||||
const formatDuration = (duration?: number | null) => {
|
||||
if (typeof duration !== 'number' || Number.isNaN(duration)) return null
|
||||
if (duration < 1000) return `${Math.max(0, Math.round(duration))}ms`
|
||||
return `${(duration / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
const mergeLiveDownload = (current: Snapshot, live: LiveDownloadProgress): Snapshot => {
|
||||
if (String(current.request_id) !== String(live.request_id)) return current
|
||||
const stageState = live.state === 'completed'
|
||||
@@ -251,6 +274,7 @@ export default function RequestTimelinePage() {
|
||||
const [releaseOptions, setReleaseOptions] = useState<ReleaseOption[]>([])
|
||||
const [historySnapshots, setHistorySnapshots] = useState<SnapshotHistory[]>([])
|
||||
const [historyActions, setHistoryActions] = useState<ActionHistory[]>([])
|
||||
const [operationProgress, setOperationProgress] = useState<OperationProgress | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!requestId) return
|
||||
@@ -427,15 +451,64 @@ export default function RequestTimelinePage() {
|
||||
const posterUrl = snapshot.artwork?.poster_url
|
||||
const resolvedPoster = posterUrl?.startsWith('http') ? posterUrl : posterUrl ? `${getApiBase()}${posterUrl}` : null
|
||||
|
||||
const trackedPost = async (label: string, url: string, init: RequestInit = {}) => {
|
||||
const operationId = typeof crypto?.randomUUID === 'function'
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
const headers = new Headers(init.headers ?? {})
|
||||
headers.set('X-Magent-Operation-ID', operationId)
|
||||
headers.set('X-Magent-Operation-Label', label)
|
||||
setOperationProgress({
|
||||
id: operationId,
|
||||
label,
|
||||
status: 'running',
|
||||
duration_ms: null,
|
||||
events: [
|
||||
{
|
||||
id: 'sending',
|
||||
service: 'Magent',
|
||||
state: 'active',
|
||||
message: 'Sending the action to Magent…',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
let stopped = false
|
||||
const refreshProgress = async () => {
|
||||
try {
|
||||
const progressResponse = await authFetch(`${getApiBase()}/operations/${operationId}`, {
|
||||
cache: 'no-store',
|
||||
})
|
||||
if (!stopped && progressResponse.ok) {
|
||||
const progress = await progressResponse.json()
|
||||
if (Array.isArray(progress?.events)) setOperationProgress(progress)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!stopped) console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
const request = authFetch(url, { ...init, method: 'POST', headers })
|
||||
const timer = window.setInterval(() => void refreshProgress(), 650)
|
||||
try {
|
||||
const response = await request
|
||||
await refreshProgress()
|
||||
return response
|
||||
} finally {
|
||||
stopped = true
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}
|
||||
|
||||
const recheckRequest = async () => {
|
||||
setBusyAction('recheck_pipeline')
|
||||
setActionError(null)
|
||||
setActionMessage(null)
|
||||
setReleaseOptions([])
|
||||
try {
|
||||
const response = await authFetch(
|
||||
const response = await trackedPost(
|
||||
'Recheck request status',
|
||||
`${getApiBase()}/requests/${snapshot.request_id}/actions/recheck`,
|
||||
{ method: 'POST' }
|
||||
)
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
@@ -476,7 +549,10 @@ export default function RequestTimelinePage() {
|
||||
setActionError(null)
|
||||
setActionMessage(null)
|
||||
try {
|
||||
const response = await authFetch(`${getApiBase()}/requests/${snapshot.request_id}/${path}`, { method: 'POST' })
|
||||
const response = await trackedPost(
|
||||
action.label,
|
||||
`${getApiBase()}/requests/${snapshot.request_id}/${path}`
|
||||
)
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
@@ -513,8 +589,9 @@ export default function RequestTimelinePage() {
|
||||
setBusyAction(`grab:${release.guid}`)
|
||||
setActionError(null)
|
||||
try {
|
||||
const response = await authFetch(`${getApiBase()}/requests/${snapshot.request_id}/actions/grab`, {
|
||||
method: 'POST',
|
||||
const response = await trackedPost(
|
||||
`Send release through ${collector}`,
|
||||
`${getApiBase()}/requests/${snapshot.request_id}/actions/grab`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(release),
|
||||
})
|
||||
@@ -592,6 +669,43 @@ export default function RequestTimelinePage() {
|
||||
{actionError ?? actionMessage}
|
||||
</div>
|
||||
)}
|
||||
{operationProgress && (
|
||||
<div className={`request-operation-progress is-${operationProgress.status}`} aria-live="polite">
|
||||
<div className="request-operation-heading">
|
||||
<div>
|
||||
<span className="request-overview-label">Remote activity</span>
|
||||
<strong>{operationProgress.label}</strong>
|
||||
</div>
|
||||
<div className="request-operation-heading-actions">
|
||||
{formatDuration(operationProgress.duration_ms) && (
|
||||
<span>{formatDuration(operationProgress.duration_ms)}</span>
|
||||
)}
|
||||
<span className={`request-operation-status is-${operationProgress.status}`}>
|
||||
{operationProgress.status === 'running' ? 'In progress' : operationProgress.status}
|
||||
</span>
|
||||
{operationProgress.status !== 'running' && (
|
||||
<button type="button" onClick={() => setOperationProgress(null)}>Dismiss</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="request-operation-events">
|
||||
{operationProgress.events.map((event) => (
|
||||
<div className={`request-operation-event is-${event.state}`} key={event.id}>
|
||||
<i aria-hidden="true" />
|
||||
<div>
|
||||
<strong>{event.service}</strong>
|
||||
<span>{event.message}</span>
|
||||
</div>
|
||||
<small>
|
||||
{event.state === 'active'
|
||||
? 'Waiting…'
|
||||
: formatDuration(event.duration_ms) ?? (event.status_code ? `HTTP ${event.status_code}` : 'Done')}
|
||||
</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="request-journey" aria-labelledby="request-journey-heading">
|
||||
|
||||
Reference in New Issue
Block a user