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(
|
||||
|
||||
Reference in New Issue
Block a user