Show live repair progress on requests
This commit is contained in:
@@ -2192,11 +2192,15 @@ async def action_replace_media(
|
|||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=400, detail="Unknown request type")
|
raise HTTPException(status_code=400, detail="Unknown request type")
|
||||||
except HTTPException as exc:
|
except HTTPException as exc:
|
||||||
|
detail = f"The media replacement could not be started: {exc.detail}"
|
||||||
|
await asyncio.to_thread(
|
||||||
|
save_action, request_id, "replace_media", "Replace media file", "failed", detail
|
||||||
|
)
|
||||||
_record_replacement_activity(
|
_record_replacement_activity(
|
||||||
linked_issue,
|
linked_issue,
|
||||||
user=user,
|
user=user,
|
||||||
event_type="replacement_failed",
|
event_type="replacement_failed",
|
||||||
message=f"The media replacement could not be started: {exc.detail}",
|
message=detail,
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -2339,16 +2343,33 @@ async def action_search_missing_media(
|
|||||||
await sonarr.search(collector_id)
|
await sonarr.search(collector_id)
|
||||||
message = "Sonarr refreshed the series and started a full missing-episode search."
|
message = "Sonarr refreshed the series and started a full missing-episode search."
|
||||||
except HTTPException as exc:
|
except HTTPException as exc:
|
||||||
|
detail = f"The missing-content search could not start: {exc.detail}"
|
||||||
|
await asyncio.to_thread(
|
||||||
|
save_action,
|
||||||
|
request_id,
|
||||||
|
"search_missing",
|
||||||
|
"Search for missing content",
|
||||||
|
"failed",
|
||||||
|
detail,
|
||||||
|
)
|
||||||
_record_replacement_activity(
|
_record_replacement_activity(
|
||||||
linked_issue,
|
linked_issue,
|
||||||
user=user,
|
user=user,
|
||||||
event_type="missing_search_failed",
|
event_type="missing_search_failed",
|
||||||
message=f"The missing-content search could not start: {exc.detail}",
|
message=detail,
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("missing content search failed request_id=%s", request_id)
|
logger.exception("missing content search failed request_id=%s", request_id)
|
||||||
detail = "Sonarr/Radarr could not start the missing-content search."
|
detail = "Sonarr/Radarr could not start the missing-content search."
|
||||||
|
await asyncio.to_thread(
|
||||||
|
save_action,
|
||||||
|
request_id,
|
||||||
|
"search_missing",
|
||||||
|
"Search for missing content",
|
||||||
|
"failed",
|
||||||
|
detail,
|
||||||
|
)
|
||||||
_record_replacement_activity(
|
_record_replacement_activity(
|
||||||
linked_issue, user=user, event_type="missing_search_failed", message=detail
|
linked_issue, user=user, event_type="missing_search_failed", message=detail
|
||||||
)
|
)
|
||||||
@@ -2416,16 +2437,33 @@ async def action_repair_subtitles(
|
|||||||
repaired_count = len(episode_ids)
|
repaired_count = len(episode_ids)
|
||||||
message = f"Bazarr started fresh {language.upper()} subtitle searches for {repaired_count} episode(s)."
|
message = f"Bazarr started fresh {language.upper()} subtitle searches for {repaired_count} episode(s)."
|
||||||
except HTTPException as exc:
|
except HTTPException as exc:
|
||||||
|
detail = f"The subtitle repair could not start: {exc.detail}"
|
||||||
|
await asyncio.to_thread(
|
||||||
|
save_action,
|
||||||
|
request_id,
|
||||||
|
"repair_subtitles",
|
||||||
|
"Repair subtitles",
|
||||||
|
"failed",
|
||||||
|
detail,
|
||||||
|
)
|
||||||
_record_replacement_activity(
|
_record_replacement_activity(
|
||||||
linked_issue,
|
linked_issue,
|
||||||
user=user,
|
user=user,
|
||||||
event_type="subtitle_repair_failed",
|
event_type="subtitle_repair_failed",
|
||||||
message=f"The subtitle repair could not start: {exc.detail}",
|
message=detail,
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("Bazarr subtitle repair failed request_id=%s", request_id)
|
logger.exception("Bazarr subtitle repair failed request_id=%s", request_id)
|
||||||
detail = "Bazarr could not start the subtitle repair."
|
detail = "Bazarr could not start the subtitle repair."
|
||||||
|
await asyncio.to_thread(
|
||||||
|
save_action,
|
||||||
|
request_id,
|
||||||
|
"repair_subtitles",
|
||||||
|
"Repair subtitles",
|
||||||
|
"failed",
|
||||||
|
detail,
|
||||||
|
)
|
||||||
_record_replacement_activity(
|
_record_replacement_activity(
|
||||||
linked_issue, user=user, event_type="subtitle_repair_failed", message=detail
|
linked_issue, user=user, event_type="subtitle_repair_failed", message=detail
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from ..clients.qbittorrent import QBittorrentClient
|
|||||||
from ..runtime import get_runtime_settings
|
from ..runtime import get_runtime_settings
|
||||||
from ..db import (
|
from ..db import (
|
||||||
save_snapshot,
|
save_snapshot,
|
||||||
|
get_recent_actions,
|
||||||
get_request_cache_payload,
|
get_request_cache_payload,
|
||||||
get_request_cache_by_id,
|
get_request_cache_by_id,
|
||||||
get_request_download_evidence,
|
get_request_download_evidence,
|
||||||
@@ -31,6 +32,8 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
JELLYFIN_SCAN_COOLDOWN_SECONDS = 300
|
JELLYFIN_SCAN_COOLDOWN_SECONDS = 300
|
||||||
_jellyfin_scan_key = "jellyfin_scan_last_at"
|
_jellyfin_scan_key = "jellyfin_scan_last_at"
|
||||||
|
REPAIR_ACTIVITY_MAX_AGE = 7 * 24 * 60 * 60
|
||||||
|
REPAIR_ACTION_IDS = {"replace_media", "search_missing", "repair_subtitles"}
|
||||||
|
|
||||||
|
|
||||||
STATUS_LABELS = {
|
STATUS_LABELS = {
|
||||||
@@ -430,6 +433,212 @@ def _torrent_progress(torrent: Dict[str, Any]) -> Optional[float]:
|
|||||||
return max(0.0, min(100.0, round(((size - amount_left) / size) * 100, 1)))
|
return max(0.0, min(100.0, round(((size - amount_left) / size) * 100, 1)))
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_action_time(value: Any) -> Optional[datetime]:
|
||||||
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||||
|
return parsed.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_repair_action(request_id: str, *, now: Optional[datetime] = None) -> Optional[Dict[str, Any]]:
|
||||||
|
current_time = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||||
|
for action in get_recent_actions(request_id, 25):
|
||||||
|
if action.get("action_id") not in REPAIR_ACTION_IDS:
|
||||||
|
continue
|
||||||
|
created_at = _parse_action_time(action.get("created_at"))
|
||||||
|
if created_at is None:
|
||||||
|
continue
|
||||||
|
age_seconds = (current_time - created_at).total_seconds()
|
||||||
|
if 0 <= age_seconds <= REPAIR_ACTIVITY_MAX_AGE:
|
||||||
|
return action
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_repair_activity(
|
||||||
|
snapshot: Snapshot,
|
||||||
|
*,
|
||||||
|
action: Optional[Dict[str, Any]],
|
||||||
|
arr_state: str,
|
||||||
|
arr_details: Dict[str, Any],
|
||||||
|
download: Dict[str, Any],
|
||||||
|
jellyfin_found: bool,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
if not action:
|
||||||
|
return None
|
||||||
|
|
||||||
|
action_id = str(action.get("action_id") or "")
|
||||||
|
collector = (
|
||||||
|
"Bazarr"
|
||||||
|
if action_id == "repair_subtitles"
|
||||||
|
else ("Sonarr" if snapshot.request_type == RequestType.tv else "Radarr")
|
||||||
|
)
|
||||||
|
action_ok = str(action.get("status") or "").lower() == "ok"
|
||||||
|
action_message = str(action.get("message") or "The repair action was recorded.")
|
||||||
|
download_state = str(download.get("state") or "not_started")
|
||||||
|
download_visible = bool(download.get("visible"))
|
||||||
|
availability = arr_details.get("availability")
|
||||||
|
if not isinstance(availability, dict):
|
||||||
|
availability = {}
|
||||||
|
missing = int(availability.get("missing") or 0)
|
||||||
|
total = int(availability.get("total") or 0)
|
||||||
|
collection_complete = arr_state == "available" and (
|
||||||
|
snapshot.request_type == RequestType.movie or (total > 0 and missing == 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
submitted_step = {
|
||||||
|
"id": "submitted",
|
||||||
|
"label": "Repair requested",
|
||||||
|
"state": "complete",
|
||||||
|
"detail": "Magent recorded the issue and started the selected repair.",
|
||||||
|
}
|
||||||
|
|
||||||
|
if not action_ok:
|
||||||
|
return {
|
||||||
|
"visible": True,
|
||||||
|
"actionId": action_id,
|
||||||
|
"state": "attention",
|
||||||
|
"headline": "Repair needs attention",
|
||||||
|
"message": action_message,
|
||||||
|
"service": collector,
|
||||||
|
"updatedAt": action.get("created_at"),
|
||||||
|
"steps": [
|
||||||
|
submitted_step,
|
||||||
|
{
|
||||||
|
"id": "collector",
|
||||||
|
"label": f"{collector} hand-off",
|
||||||
|
"state": "attention",
|
||||||
|
"detail": action_message,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
if action_id == "repair_subtitles":
|
||||||
|
return {
|
||||||
|
"visible": True,
|
||||||
|
"actionId": action_id,
|
||||||
|
"state": "searching",
|
||||||
|
"headline": "Subtitle repair is running",
|
||||||
|
"message": (
|
||||||
|
f"{action_message} Bazarr is checking the configured subtitle providers; "
|
||||||
|
"the issue can be confirmed once the replacement track is available."
|
||||||
|
),
|
||||||
|
"service": collector,
|
||||||
|
"updatedAt": action.get("created_at"),
|
||||||
|
"steps": [
|
||||||
|
submitted_step,
|
||||||
|
{
|
||||||
|
"id": "collector",
|
||||||
|
"label": "Bazarr accepted the search",
|
||||||
|
"state": "complete",
|
||||||
|
"detail": action_message,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "result",
|
||||||
|
"label": "Subtitle result",
|
||||||
|
"state": "active",
|
||||||
|
"detail": "Waiting for Bazarr to find and apply a suitable subtitle track.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
if collection_complete:
|
||||||
|
headline = "Repair collected"
|
||||||
|
message = (
|
||||||
|
f"{collector} now reports the replacement file as collected. "
|
||||||
|
+ (
|
||||||
|
"It is also available in Grizzlyflix."
|
||||||
|
if jellyfin_found
|
||||||
|
else "Grizzlyflix is indexing the updated file now."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
state = "complete" if jellyfin_found else "indexing"
|
||||||
|
download_step_state = "complete"
|
||||||
|
available_step_state = "complete" if jellyfin_found else "active"
|
||||||
|
elif download_visible and download_state in {"downloading", "paused", "completed", "error", "missing"}:
|
||||||
|
state = {
|
||||||
|
"downloading": "downloading",
|
||||||
|
"completed": "importing",
|
||||||
|
"paused": "attention",
|
||||||
|
"error": "attention",
|
||||||
|
"missing": "attention",
|
||||||
|
}[download_state]
|
||||||
|
headline = {
|
||||||
|
"downloading": "Replacement download in progress",
|
||||||
|
"completed": "Replacement downloaded — waiting for import",
|
||||||
|
"paused": "Replacement download paused",
|
||||||
|
"error": "Replacement download cannot be checked",
|
||||||
|
"missing": "Replacement hand-off needs checking",
|
||||||
|
}[download_state]
|
||||||
|
message = {
|
||||||
|
"downloading": "The replacement is downloading now.",
|
||||||
|
"paused": "The replacement download is paused and needs attention.",
|
||||||
|
"completed": f"The download has finished and is waiting for {collector} to import it.",
|
||||||
|
"error": "Magent cannot currently read the replacement download from qBittorrent.",
|
||||||
|
"missing": "The collector reported a download, but it is not currently visible in qBittorrent.",
|
||||||
|
}[download_state]
|
||||||
|
download_step_state = "active" if download_state == "downloading" else (
|
||||||
|
"complete" if download_state == "completed" else "attention"
|
||||||
|
)
|
||||||
|
available_step_state = "waiting"
|
||||||
|
else:
|
||||||
|
state = "searching"
|
||||||
|
headline = "Replacement search in progress"
|
||||||
|
message = (
|
||||||
|
f"{action_message} {collector} has accepted the search, but no replacement download "
|
||||||
|
"has been selected yet. Magent will keep checking."
|
||||||
|
)
|
||||||
|
download_step_state = "waiting"
|
||||||
|
available_step_state = "waiting"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"visible": True,
|
||||||
|
"actionId": action_id,
|
||||||
|
"state": state,
|
||||||
|
"headline": headline,
|
||||||
|
"message": message,
|
||||||
|
"service": collector,
|
||||||
|
"updatedAt": action.get("created_at"),
|
||||||
|
"steps": [
|
||||||
|
submitted_step,
|
||||||
|
{
|
||||||
|
"id": "collector",
|
||||||
|
"label": f"{collector} accepted the search",
|
||||||
|
"state": "complete",
|
||||||
|
"detail": action_message,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "download",
|
||||||
|
"label": "Replacement download",
|
||||||
|
"state": download_step_state,
|
||||||
|
"detail": (
|
||||||
|
str(download.get("summary") or "Waiting for a suitable replacement release.")
|
||||||
|
if download_step_state != "waiting"
|
||||||
|
else "Waiting for a suitable release to be selected."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "available",
|
||||||
|
"label": "Updated media available",
|
||||||
|
"state": available_step_state,
|
||||||
|
"detail": (
|
||||||
|
"The repaired title is available in Grizzlyflix."
|
||||||
|
if jellyfin_found and collection_complete
|
||||||
|
else (
|
||||||
|
"The media server is indexing the replacement."
|
||||||
|
if collection_complete
|
||||||
|
else "Waiting for download and import to finish."
|
||||||
|
)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _build_presentation(
|
def _build_presentation(
|
||||||
snapshot: Snapshot,
|
snapshot: Snapshot,
|
||||||
*,
|
*,
|
||||||
@@ -1303,6 +1512,17 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
|||||||
jellyfin_found=jellyfin_available,
|
jellyfin_found=jellyfin_available,
|
||||||
jellyfin_link=jellyfin_link,
|
jellyfin_link=jellyfin_link,
|
||||||
)
|
)
|
||||||
|
repair_action = await asyncio.to_thread(_latest_repair_action, request_id)
|
||||||
|
repair_activity = _build_repair_activity(
|
||||||
|
snapshot,
|
||||||
|
action=repair_action,
|
||||||
|
arr_state=arr_state,
|
||||||
|
arr_details=arr_details,
|
||||||
|
download=download_presentation,
|
||||||
|
jellyfin_found=jellyfin_available,
|
||||||
|
)
|
||||||
|
if repair_activity:
|
||||||
|
snapshot.presentation["repairActivity"] = repair_activity
|
||||||
status_presentation = snapshot.presentation.get("status")
|
status_presentation = snapshot.presentation.get("status")
|
||||||
if isinstance(status_presentation, dict) and status_presentation.get("meaning"):
|
if isinstance(status_presentation, dict) and status_presentation.get("meaning"):
|
||||||
snapshot.state_reason = str(status_presentation["meaning"])
|
snapshot.state_reason = str(status_presentation["meaning"])
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ from backend.app.services.operation_progress import (
|
|||||||
from backend.app.services.snapshot import (
|
from backend.app.services.snapshot import (
|
||||||
_apply_arr_identity,
|
_apply_arr_identity,
|
||||||
_build_presentation,
|
_build_presentation,
|
||||||
|
_build_repair_activity,
|
||||||
_episode_availability,
|
_episode_availability,
|
||||||
_torrent_progress,
|
_torrent_progress,
|
||||||
)
|
)
|
||||||
@@ -396,6 +397,93 @@ class RequestVisibilityTests(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class RequestPresentationTests(unittest.TestCase):
|
class RequestPresentationTests(unittest.TestCase):
|
||||||
|
def test_repair_activity_shows_collector_search_before_download(self) -> None:
|
||||||
|
snapshot = Snapshot(
|
||||||
|
request_id="144",
|
||||||
|
title="Toy Story 2",
|
||||||
|
request_type=RequestType.movie,
|
||||||
|
state=NormalizedState.searching,
|
||||||
|
)
|
||||||
|
|
||||||
|
activity = _build_repair_activity(
|
||||||
|
snapshot,
|
||||||
|
action={
|
||||||
|
"action_id": "replace_media",
|
||||||
|
"status": "ok",
|
||||||
|
"message": "Radarr removed the file and started a replacement search.",
|
||||||
|
"created_at": "2026-09-01T09:06:55+00:00",
|
||||||
|
},
|
||||||
|
arr_state="searching",
|
||||||
|
arr_details={"availability": {"available": 0, "missing": 1, "total": 1}},
|
||||||
|
download={"visible": False, "state": "not_started", "torrents": []},
|
||||||
|
jellyfin_found=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNotNone(activity)
|
||||||
|
self.assertEqual(activity["state"], "searching")
|
||||||
|
self.assertEqual(activity["headline"], "Replacement search in progress")
|
||||||
|
self.assertEqual(activity["steps"][1]["state"], "complete")
|
||||||
|
self.assertEqual(activity["steps"][2]["state"], "waiting")
|
||||||
|
|
||||||
|
def test_repair_activity_tracks_replacement_download(self) -> None:
|
||||||
|
snapshot = Snapshot(
|
||||||
|
request_id="144",
|
||||||
|
title="Toy Story 2",
|
||||||
|
request_type=RequestType.movie,
|
||||||
|
state=NormalizedState.downloading,
|
||||||
|
)
|
||||||
|
|
||||||
|
activity = _build_repair_activity(
|
||||||
|
snapshot,
|
||||||
|
action={
|
||||||
|
"action_id": "replace_media",
|
||||||
|
"status": "ok",
|
||||||
|
"message": "Radarr started a replacement search.",
|
||||||
|
"created_at": "2026-09-01T09:06:55+00:00",
|
||||||
|
},
|
||||||
|
arr_state="searching",
|
||||||
|
arr_details={"availability": {"available": 0, "missing": 1, "total": 1}},
|
||||||
|
download={
|
||||||
|
"visible": True,
|
||||||
|
"state": "downloading",
|
||||||
|
"summary": "Downloading (1 active).",
|
||||||
|
"torrents": [{"progress": 0.25}],
|
||||||
|
},
|
||||||
|
jellyfin_found=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNotNone(activity)
|
||||||
|
self.assertEqual(activity["state"], "downloading")
|
||||||
|
self.assertEqual(activity["headline"], "Replacement download in progress")
|
||||||
|
self.assertEqual(activity["steps"][2]["state"], "active")
|
||||||
|
|
||||||
|
def test_repair_activity_reports_collected_file_and_media_index(self) -> None:
|
||||||
|
snapshot = Snapshot(
|
||||||
|
request_id="144",
|
||||||
|
title="Toy Story 2",
|
||||||
|
request_type=RequestType.movie,
|
||||||
|
state=NormalizedState.importing,
|
||||||
|
)
|
||||||
|
|
||||||
|
activity = _build_repair_activity(
|
||||||
|
snapshot,
|
||||||
|
action={
|
||||||
|
"action_id": "replace_media",
|
||||||
|
"status": "ok",
|
||||||
|
"message": "Radarr started a replacement search.",
|
||||||
|
"created_at": "2026-09-01T09:06:55+00:00",
|
||||||
|
},
|
||||||
|
arr_state="available",
|
||||||
|
arr_details={"availability": {"available": 1, "missing": 0, "total": 1}},
|
||||||
|
download={"visible": False, "state": "not_started", "torrents": []},
|
||||||
|
jellyfin_found=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNotNone(activity)
|
||||||
|
self.assertEqual(activity["state"], "indexing")
|
||||||
|
self.assertEqual(activity["steps"][2]["state"], "complete")
|
||||||
|
self.assertEqual(activity["steps"][3]["state"], "active")
|
||||||
|
|
||||||
def test_torrent_progress_keeps_tenths_for_live_updates(self) -> None:
|
def test_torrent_progress_keeps_tenths_for_live_updates(self) -> None:
|
||||||
self.assertEqual(_torrent_progress({"progress": 0.1344}), 13.4)
|
self.assertEqual(_torrent_progress({"progress": 0.1344}), 13.4)
|
||||||
|
|
||||||
|
|||||||
@@ -1992,6 +1992,7 @@ button:disabled,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.request-overview,
|
.request-overview,
|
||||||
|
.request-repair-activity,
|
||||||
.request-journey,
|
.request-journey,
|
||||||
.request-advanced {
|
.request-advanced {
|
||||||
border: 1px solid var(--ops-line);
|
border: 1px solid var(--ops-line);
|
||||||
@@ -2005,6 +2006,63 @@ button:disabled,
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.request-repair-activity {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
border-color: rgba(126, 215, 255, 0.3);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 6% 0%, rgba(14, 165, 233, 0.13), transparent 34%),
|
||||||
|
rgba(255, 255, 255, 0.018);
|
||||||
|
}
|
||||||
|
.request-repair-activity.is-complete { border-color: rgba(72, 224, 178, 0.34); }
|
||||||
|
.request-repair-activity.is-attention { border-color: rgba(255, 141, 157, 0.4); }
|
||||||
|
.request-repair-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
.request-repair-heading h2 { margin: 0; font-size: clamp(1.15rem, 2vw, 1.55rem); }
|
||||||
|
.request-repair-heading p { max-width: 88ch; margin: 7px 0 0; color: var(--ops-muted); line-height: 1.5; }
|
||||||
|
.request-repair-meta { display: grid; justify-items: end; gap: 7px; color: var(--ops-muted); }
|
||||||
|
.request-repair-meta small { white-space: nowrap; font-size: 0.7rem; }
|
||||||
|
.request-repair-steps {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
.request-repair-step {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
gap: 9px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--ops-line-soft);
|
||||||
|
border-radius: var(--ops-radius);
|
||||||
|
background: rgba(255, 255, 255, 0.022);
|
||||||
|
}
|
||||||
|
.request-repair-step > i {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
margin-top: 3px;
|
||||||
|
border: 2px solid currentColor;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--ops-muted);
|
||||||
|
}
|
||||||
|
.request-repair-step.is-complete > i { color: var(--request-green); background: currentColor; }
|
||||||
|
.request-repair-step.is-active > i {
|
||||||
|
color: var(--request-cyan);
|
||||||
|
box-shadow: 0 0 12px currentColor;
|
||||||
|
animation: request-operation-pulse 1.15s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.request-repair-step.is-attention > i { color: var(--request-red); background: currentColor; }
|
||||||
|
.request-repair-step > div { display: grid; gap: 4px; min-width: 0; }
|
||||||
|
.request-repair-step strong { color: var(--ops-text); font-size: 0.76rem; }
|
||||||
|
.request-repair-step span { color: var(--ops-muted); font-size: 0.72rem; line-height: 1.4; }
|
||||||
|
|
||||||
.request-overview-block {
|
.request-overview-block {
|
||||||
grid-column: span 6;
|
grid-column: span 6;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -2288,6 +2346,7 @@ button:disabled,
|
|||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
.request-stage { grid-column: span 6; }
|
.request-stage { grid-column: span 6; }
|
||||||
|
.request-repair-steps { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
@@ -2309,6 +2368,9 @@ button:disabled,
|
|||||||
.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 { align-items: flex-start; flex-direction: column; }
|
||||||
|
.request-repair-heading { flex-direction: column; }
|
||||||
|
.request-repair-meta { justify-items: start; }
|
||||||
|
.request-repair-steps { grid-template-columns: 1fr; }
|
||||||
.request-operation-heading-actions { width: 100%; flex-wrap: wrap; }
|
.request-operation-heading-actions { width: 100%; flex-wrap: wrap; }
|
||||||
.request-operation-event { align-items: flex-start; }
|
.request-operation-event { align-items: flex-start; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,24 @@ type PipelineStage = {
|
|||||||
link?: string | null
|
link?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RepairActivityStep = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
state: 'complete' | 'active' | 'attention' | 'waiting' | string
|
||||||
|
detail: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RepairActivity = {
|
||||||
|
visible?: boolean
|
||||||
|
actionId?: string
|
||||||
|
state?: 'searching' | 'downloading' | 'importing' | 'indexing' | 'complete' | 'attention' | string
|
||||||
|
headline?: string
|
||||||
|
message?: string
|
||||||
|
service?: string
|
||||||
|
updatedAt?: string | null
|
||||||
|
steps?: RepairActivityStep[]
|
||||||
|
}
|
||||||
|
|
||||||
type Snapshot = {
|
type Snapshot = {
|
||||||
request_id: string
|
request_id: string
|
||||||
title: string
|
title: string
|
||||||
@@ -57,6 +75,7 @@ type Snapshot = {
|
|||||||
}
|
}
|
||||||
nextStep?: { title?: string; description?: string; actionIds?: string[] }
|
nextStep?: { title?: string; description?: string; actionIds?: string[] }
|
||||||
pipeline?: PipelineStage[]
|
pipeline?: PipelineStage[]
|
||||||
|
repairActivity?: RepairActivity
|
||||||
}
|
}
|
||||||
raw?: Record<string, any>
|
raw?: Record<string, any>
|
||||||
}
|
}
|
||||||
@@ -288,6 +307,10 @@ export default function RequestTimelinePage() {
|
|||||||
(stage) => stage.id === 'available' && stage.state === 'active'
|
(stage) => stage.id === 'available' && stage.state === 'active'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
const repairIsActive = Boolean(
|
||||||
|
snapshot?.presentation?.repairActivity?.visible &&
|
||||||
|
!['complete', 'attention'].includes(snapshot.presentation.repairActivity.state ?? '')
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!requestId) return
|
if (!requestId) return
|
||||||
@@ -367,12 +390,15 @@ export default function RequestTimelinePage() {
|
|||||||
if (!stopped) console.error(error)
|
if (!stopped) console.error(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const timer = window.setInterval(() => void refresh(), awaitingMediaIndex ? 5_000 : 15_000)
|
const timer = window.setInterval(
|
||||||
|
() => void refresh(),
|
||||||
|
awaitingMediaIndex || repairIsActive ? 5_000 : 15_000,
|
||||||
|
)
|
||||||
return () => {
|
return () => {
|
||||||
stopped = true
|
stopped = true
|
||||||
window.clearInterval(timer)
|
window.clearInterval(timer)
|
||||||
}
|
}
|
||||||
}, [awaitingMediaIndex, requestId, router])
|
}, [awaitingMediaIndex, repairIsActive, requestId, router])
|
||||||
|
|
||||||
const liveDownloadKey = useMemo(() => {
|
const liveDownloadKey = useMemo(() => {
|
||||||
const downloadStage = snapshot?.presentation?.pipeline?.find((stage) => stage.id === 'download')
|
const downloadStage = snapshot?.presentation?.pipeline?.find((stage) => stage.id === 'download')
|
||||||
@@ -470,6 +496,7 @@ export default function RequestTimelinePage() {
|
|||||||
const statusLabel = presentation.status?.label ?? fallbackStatusLabel(snapshot.state)
|
const statusLabel = presentation.status?.label ?? fallbackStatusLabel(snapshot.state)
|
||||||
const statusMeaning = presentation.status?.meaning ?? snapshot.state_reason ?? 'Magent is checking this request.'
|
const statusMeaning = presentation.status?.meaning ?? snapshot.state_reason ?? 'Magent is checking this request.'
|
||||||
const download = presentation.download
|
const download = presentation.download
|
||||||
|
const repairActivity = presentation.repairActivity
|
||||||
const downloadVisible = Boolean(download?.visible)
|
const downloadVisible = Boolean(download?.visible)
|
||||||
const nextStep = presentation.nextStep ?? {
|
const nextStep = presentation.nextStep ?? {
|
||||||
title: snapshot.actions[0]?.label ?? 'No action needed right now',
|
title: snapshot.actions[0]?.label ?? 'No action needed right now',
|
||||||
@@ -753,6 +780,35 @@ export default function RequestTimelinePage() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{repairActivity?.visible && (
|
||||||
|
<section className={`request-repair-activity is-${repairActivity.state ?? 'searching'}`} aria-live="polite">
|
||||||
|
<div className="request-repair-heading">
|
||||||
|
<div>
|
||||||
|
<span className="section-kicker">Repair activity</span>
|
||||||
|
<h2>{repairActivity.headline ?? 'Repair in progress'}</h2>
|
||||||
|
<p>{repairActivity.message ?? 'Magent is checking the repair with the connected services.'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="request-repair-meta">
|
||||||
|
<span className={`request-operation-status is-${repairActivity.state === 'attention' ? 'error' : repairActivity.state === 'complete' ? 'complete' : 'running'}`}>
|
||||||
|
{repairActivity.state === 'complete' ? 'Complete' : repairActivity.state === 'attention' ? 'Attention' : 'Live'}
|
||||||
|
</span>
|
||||||
|
{repairActivity.updatedAt && <small>Updated {formatWhen(repairActivity.updatedAt)}</small>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="request-repair-steps">
|
||||||
|
{(repairActivity.steps ?? []).map((step) => (
|
||||||
|
<div className={`request-repair-step is-${step.state}`} key={step.id}>
|
||||||
|
<i aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<strong>{step.label}</strong>
|
||||||
|
<span>{step.detail}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<section className="request-journey" aria-labelledby="request-journey-heading">
|
<section className="request-journey" aria-labelledby="request-journey-heading">
|
||||||
<div className="request-journey-heading">
|
<div className="request-journey-heading">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in New Issue
Block a user