From 963506d098603b6d6b6e750e8028d07cb6286d66 Mon Sep 17 00:00:00 2001 From: Zak Bearman Date: Sun, 30 Aug 2026 21:23:20 +1200 Subject: [PATCH] Show live feedback for remote request actions --- backend/app/clients/base.py | 58 +++++- backend/app/clients/jellyfin.py | 57 +++++- backend/app/clients/qbittorrent.py | 92 +++++++-- backend/app/main.py | 26 +++ backend/app/routers/operations.py | 19 ++ backend/app/services/operation_progress.py | 206 +++++++++++++++++++++ backend/tests/test_backend_quality.py | 40 ++++ frontend/app/ops-redesign.css | 73 ++++++++ frontend/app/requests/[id]/page.tsx | 124 ++++++++++++- 9 files changed, 666 insertions(+), 29 deletions(-) create mode 100644 backend/app/routers/operations.py create mode 100644 backend/app/services/operation_progress.py diff --git a/backend/app/clients/base.py b/backend/app/clients/base.py index 95ad889..86819a7 100644 --- a/backend/app/clients/base.py +++ b/backend/app/clients/base.py @@ -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( diff --git a/backend/app/clients/jellyfin.py b/backend/app/clients/jellyfin.py index d735b41..e744d3b 100644 --- a/backend/app/clients/jellyfin.py +++ b/backend/app/clients/jellyfin.py @@ -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 diff --git a/backend/app/clients/qbittorrent.py b/backend/app/clients/qbittorrent.py index 88f2823..c11e126 100644 --- a/backend/app/clients/qbittorrent.py +++ b/backend/app/clients/qbittorrent.py @@ -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: diff --git a/backend/app/main.py b/backend/app/main.py index 5db75d8..823ef73 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/routers/operations.py b/backend/app/routers/operations.py new file mode 100644 index 0000000..48264fb --- /dev/null +++ b/backend/app/routers/operations.py @@ -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 diff --git a/backend/app/services/operation_progress.py b/backend/app/services/operation_progress.py new file mode 100644 index 0000000..9bf1183 --- /dev/null +++ b/backend/app/services/operation_progress.py @@ -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 diff --git a/backend/tests/test_backend_quality.py b/backend/tests/test_backend_quality.py index fd8f0be..7a20788 100644 --- a/backend/tests/test_backend_quality.py +++ b/backend/tests/test_backend_quality.py @@ -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( diff --git a/frontend/app/ops-redesign.css b/frontend/app/ops-redesign.css index 9748787..f6fedf4 100644 --- a/frontend/app/ops-redesign.css +++ b/frontend/app/ops-redesign.css @@ -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; } } diff --git a/frontend/app/requests/[id]/page.tsx b/frontend/app/requests/[id]/page.tsx index 8609a9f..a171ac9 100644 --- a/frontend/app/requests/[id]/page.tsx +++ b/frontend/app/requests/[id]/page.tsx @@ -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) => { 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([]) const [historySnapshots, setHistorySnapshots] = useState([]) const [historyActions, setHistoryActions] = useState([]) + const [operationProgress, setOperationProgress] = useState(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} )} + {operationProgress && ( +
+
+
+ Remote activity + {operationProgress.label} +
+
+ {formatDuration(operationProgress.duration_ms) && ( + {formatDuration(operationProgress.duration_ms)} + )} + + {operationProgress.status === 'running' ? 'In progress' : operationProgress.status} + + {operationProgress.status !== 'running' && ( + + )} +
+
+
+ {operationProgress.events.map((event) => ( +
+