Show live feedback for remote request actions
Magent CI/CD / verify (push) Successful in 10m51s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 15s

This commit is contained in:
2026-08-30 21:23:20 +12:00
parent 02245d365e
commit 963506d098
9 changed files with 666 additions and 29 deletions
+55 -3
View File
@@ -4,6 +4,39 @@ import time
import httpx import httpx
from ..logging_config import sanitize_headers, sanitize_value 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: class ApiClient:
@@ -43,6 +76,9 @@ class ApiClient:
return None return None
url = f"{self.base_url}{path}" url = f"{self.base_url}{path}"
started_at = time.perf_counter() 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( self.logger.debug(
"outbound request started method=%s url=%s params=%s payload=%s headers=%s", "outbound request started method=%s url=%s params=%s payload=%s headers=%s",
method, method,
@@ -69,9 +105,14 @@ class ApiClient:
response.status_code, response.status_code,
duration_ms, duration_ms,
) )
if not response.content: result = response.json() if response.content else None
return None finish_remote_call(
return response.json() 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: except httpx.HTTPStatusError as exc:
duration_ms = round((time.perf_counter() - started_at) * 1000, 2) duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
response = exc.response response = exc.response
@@ -85,6 +126,12 @@ class ApiClient:
duration_ms, duration_ms,
self._response_summary(response), 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 raise
except Exception: except Exception:
duration_ms = round((time.perf_counter() - started_at) * 1000, 2) duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
@@ -94,6 +141,11 @@ class ApiClient:
url, url,
duration_ms, 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 raise
async def get( async def get(
+50 -7
View File
@@ -1,6 +1,8 @@
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
import httpx import httpx
import time
from .base import ApiClient from .base import ApiClient
from ..services.operation_progress import finish_remote_call, start_remote_call
class JellyfinClient(ApiClient): class JellyfinClient(ApiClient):
@@ -167,6 +169,8 @@ class JellyfinClient(ApiClient):
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
if not self.base_url or not self.api_key: if not self.base_url or not self.api_key:
return None 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" url = f"{self.base_url}/Items"
params = { params = {
"SearchTerm": term, "SearchTerm": term,
@@ -175,10 +179,29 @@ class JellyfinClient(ApiClient):
"Limit": limit, "Limit": limit,
} }
headers = self._emby_headers() headers = self._emby_headers()
async with httpx.AsyncClient(timeout=10.0) as client: try:
response = await client.get(url, headers=headers, params=params) async with httpx.AsyncClient(timeout=10.0) as client:
response.raise_for_status() response = await client.get(url, headers=headers, params=params)
return response.json() 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]]: async def get_system_info(self) -> Optional[Dict[str, Any]]:
if not self.base_url or not self.api_key: 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: async def refresh_library(self, recursive: bool = True) -> None:
if not self.base_url or not self.api_key: if not self.base_url or not self.api_key:
return None 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" url = f"{self.base_url}/Library/Refresh"
headers = self._emby_headers() headers = self._emby_headers()
params = {"Recursive": "true" if recursive else "false"} params = {"Recursive": "true" if recursive else "false"}
async with httpx.AsyncClient(timeout=10.0) as client: try:
response = await client.post(url, headers=headers, params=params) async with httpx.AsyncClient(timeout=10.0) as client:
response.raise_for_status() 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
+78 -14
View File
@@ -1,7 +1,9 @@
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
import httpx import httpx
import logging import logging
import time
from .base import ApiClient from .base import ApiClient
from ..services.operation_progress import finish_remote_call, start_remote_call
class QBittorrentClient(ApiClient): class QBittorrentClient(ApiClient):
@@ -31,28 +33,90 @@ class QBittorrentClient(ApiClient):
async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]: async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
if not self.base_url: if not self.base_url:
return None return None
async with httpx.AsyncClient(timeout=10.0) as client: started_at = time.perf_counter()
await self._login(client) operation_event_id = start_remote_call("qBittorrent", "Checking qBittorrent for matching downloads…")
response = await client.get(f"{self.base_url}{path}", params=params) try:
response.raise_for_status() async with httpx.AsyncClient(timeout=10.0) as client:
return response.json() 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]: async def _get_text(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[str]:
if not self.base_url: if not self.base_url:
return None return None
async with httpx.AsyncClient(timeout=10.0) as client: started_at = time.perf_counter()
await self._login(client) operation_event_id = start_remote_call("qBittorrent")
response = await client.get(f"{self.base_url}{path}", params=params) try:
response.raise_for_status() async with httpx.AsyncClient(timeout=10.0) as client:
return response.text.strip() 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: async def _post_form(self, path: str, data: Dict[str, Any]) -> None:
if not self.base_url: if not self.base_url:
return None return None
async with httpx.AsyncClient(timeout=10.0) as client: started_at = time.perf_counter()
await self._login(client) operation_event_id = start_remote_call("qBittorrent")
response = await client.post(f"{self.base_url}{path}", data=data) try:
response.raise_for_status() 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: async def is_webui_reachable(self) -> bool:
if not self.base_url: if not self.base_url:
+26
View File
@@ -25,7 +25,14 @@ from .routers.feedback import router as feedback_router
from .routers.site import router as site_router from .routers.site import router as site_router
from .routers.events import router as events_router from .routers.events import router as events_router
from .routers.portal import router as portal_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.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 ( from .logging_config import (
bind_request_id, bind_request_id,
configure_logging, configure_logging,
@@ -59,6 +66,14 @@ app.add_middleware(
async def log_requests_and_add_security_headers(request: Request, call_next): 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] request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex[:12]
token = bind_request_id(request_id) 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 request.state.request_id = request_id
started_at = time.perf_counter() started_at = time.perf_counter()
body = await request.body() body = await request.body()
@@ -101,6 +116,9 @@ async def log_requests_and_add_security_headers(request: Request, call_next):
request.url.path, request.url.path,
duration_ms, 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) reset_request_id(token)
raise 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) reset_request_id(token)
return response return response
@@ -244,3 +269,4 @@ app.include_router(feedback_router)
app.include_router(site_router) app.include_router(site_router)
app.include_router(events_router) app.include_router(events_router)
app.include_router(portal_router) app.include_router(portal_router)
app.include_router(operations_router)
+19
View File
@@ -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
+206
View File
@@ -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
+40
View File
@@ -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.routers import status as status_router
from backend.app.security import PASSWORD_POLICY_MESSAGE, validate_password_policy from backend.app.security import PASSWORD_POLICY_MESSAGE, validate_password_policy
from backend.app.services import password_reset 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 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()) 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): class SiteInfoTests(unittest.TestCase):
def test_site_public_exposes_requests_navigation_toggle(self) -> None: def test_site_public_exposes_requests_navigation_toggle(self) -> None:
runtime = SimpleNamespace( runtime = SimpleNamespace(
+73
View File
@@ -2012,6 +2012,76 @@ button:disabled,
} }
.request-action-feedback.is-error { color: var(--request-red); background: rgba(255, 86, 113, 0.08); } .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 { display: grid; gap: 18px; padding: 20px; }
.request-journey-heading, .request-journey-heading,
.request-release-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; } .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-stage-actions { display: grid; }
.request-action-row button, .request-action-row button,
.request-release button { width: 100%; } .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; }
} }
+119 -5
View File
@@ -101,6 +101,23 @@ type LiveDownloadProgress = {
updated_at: string 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) => { const readApiError = async (response: Response, fallback: string) => {
try { try {
const contentType = response.headers.get('content-type') ?? '' 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 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 => { const mergeLiveDownload = (current: Snapshot, live: LiveDownloadProgress): Snapshot => {
if (String(current.request_id) !== String(live.request_id)) return current if (String(current.request_id) !== String(live.request_id)) return current
const stageState = live.state === 'completed' const stageState = live.state === 'completed'
@@ -251,6 +274,7 @@ export default function RequestTimelinePage() {
const [releaseOptions, setReleaseOptions] = useState<ReleaseOption[]>([]) const [releaseOptions, setReleaseOptions] = useState<ReleaseOption[]>([])
const [historySnapshots, setHistorySnapshots] = useState<SnapshotHistory[]>([]) const [historySnapshots, setHistorySnapshots] = useState<SnapshotHistory[]>([])
const [historyActions, setHistoryActions] = useState<ActionHistory[]>([]) const [historyActions, setHistoryActions] = useState<ActionHistory[]>([])
const [operationProgress, setOperationProgress] = useState<OperationProgress | null>(null)
useEffect(() => { useEffect(() => {
if (!requestId) return if (!requestId) return
@@ -427,15 +451,64 @@ export default function RequestTimelinePage() {
const posterUrl = snapshot.artwork?.poster_url const posterUrl = snapshot.artwork?.poster_url
const resolvedPoster = posterUrl?.startsWith('http') ? posterUrl : posterUrl ? `${getApiBase()}${posterUrl}` : null 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 () => { const recheckRequest = async () => {
setBusyAction('recheck_pipeline') setBusyAction('recheck_pipeline')
setActionError(null) setActionError(null)
setActionMessage(null) setActionMessage(null)
setReleaseOptions([]) setReleaseOptions([])
try { try {
const response = await authFetch( const response = await trackedPost(
'Recheck request status',
`${getApiBase()}/requests/${snapshot.request_id}/actions/recheck`, `${getApiBase()}/requests/${snapshot.request_id}/actions/recheck`,
{ method: 'POST' }
) )
if (response.status === 401) { if (response.status === 401) {
clearToken() clearToken()
@@ -476,7 +549,10 @@ export default function RequestTimelinePage() {
setActionError(null) setActionError(null)
setActionMessage(null) setActionMessage(null)
try { 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) { if (response.status === 401) {
clearToken() clearToken()
router.push('/login') router.push('/login')
@@ -513,8 +589,9 @@ export default function RequestTimelinePage() {
setBusyAction(`grab:${release.guid}`) setBusyAction(`grab:${release.guid}`)
setActionError(null) setActionError(null)
try { try {
const response = await authFetch(`${getApiBase()}/requests/${snapshot.request_id}/actions/grab`, { const response = await trackedPost(
method: 'POST', `Send release through ${collector}`,
`${getApiBase()}/requests/${snapshot.request_id}/actions/grab`, {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(release), body: JSON.stringify(release),
}) })
@@ -592,6 +669,43 @@ export default function RequestTimelinePage() {
{actionError ?? actionMessage} {actionError ?? actionMessage}
</div> </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>
<section className="request-journey" aria-labelledby="request-journey-heading"> <section className="request-journey" aria-labelledby="request-journey-heading">