Show live repair progress on requests
This commit is contained in:
@@ -2192,11 +2192,15 @@ async def action_replace_media(
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Unknown request type")
|
||||
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(
|
||||
linked_issue,
|
||||
user=user,
|
||||
event_type="replacement_failed",
|
||||
message=f"The media replacement could not be started: {exc.detail}",
|
||||
message=detail,
|
||||
)
|
||||
raise
|
||||
except Exception as exc:
|
||||
@@ -2339,16 +2343,33 @@ async def action_search_missing_media(
|
||||
await sonarr.search(collector_id)
|
||||
message = "Sonarr refreshed the series and started a full missing-episode search."
|
||||
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(
|
||||
linked_issue,
|
||||
user=user,
|
||||
event_type="missing_search_failed",
|
||||
message=f"The missing-content search could not start: {exc.detail}",
|
||||
message=detail,
|
||||
)
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("missing content search failed request_id=%s", request_id)
|
||||
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(
|
||||
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)
|
||||
message = f"Bazarr started fresh {language.upper()} subtitle searches for {repaired_count} episode(s)."
|
||||
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(
|
||||
linked_issue,
|
||||
user=user,
|
||||
event_type="subtitle_repair_failed",
|
||||
message=f"The subtitle repair could not start: {exc.detail}",
|
||||
message=detail,
|
||||
)
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("Bazarr subtitle repair failed request_id=%s", request_id)
|
||||
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(
|
||||
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 ..db import (
|
||||
save_snapshot,
|
||||
get_recent_actions,
|
||||
get_request_cache_payload,
|
||||
get_request_cache_by_id,
|
||||
get_request_download_evidence,
|
||||
@@ -31,6 +32,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
JELLYFIN_SCAN_COOLDOWN_SECONDS = 300
|
||||
_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 = {
|
||||
@@ -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)))
|
||||
|
||||
|
||||
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(
|
||||
snapshot: Snapshot,
|
||||
*,
|
||||
@@ -1303,6 +1512,17 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
||||
jellyfin_found=jellyfin_available,
|
||||
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")
|
||||
if isinstance(status_presentation, dict) and status_presentation.get("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 (
|
||||
_apply_arr_identity,
|
||||
_build_presentation,
|
||||
_build_repair_activity,
|
||||
_episode_availability,
|
||||
_torrent_progress,
|
||||
)
|
||||
@@ -396,6 +397,93 @@ class RequestVisibilityTests(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:
|
||||
self.assertEqual(_torrent_progress({"progress": 0.1344}), 13.4)
|
||||
|
||||
|
||||
@@ -1992,6 +1992,7 @@ button:disabled,
|
||||
}
|
||||
|
||||
.request-overview,
|
||||
.request-repair-activity,
|
||||
.request-journey,
|
||||
.request-advanced {
|
||||
border: 1px solid var(--ops-line);
|
||||
@@ -2005,6 +2006,63 @@ button:disabled,
|
||||
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 {
|
||||
grid-column: span 6;
|
||||
display: grid;
|
||||
@@ -2288,6 +2346,7 @@ button:disabled,
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.request-stage { grid-column: span 6; }
|
||||
.request-repair-steps { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
@@ -2309,6 +2368,9 @@ button:disabled,
|
||||
.request-action-row button,
|
||||
.request-release button { width: 100%; }
|
||||
.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-event { align-items: flex-start; }
|
||||
}
|
||||
|
||||
@@ -36,6 +36,24 @@ type PipelineStage = {
|
||||
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 = {
|
||||
request_id: string
|
||||
title: string
|
||||
@@ -57,6 +75,7 @@ type Snapshot = {
|
||||
}
|
||||
nextStep?: { title?: string; description?: string; actionIds?: string[] }
|
||||
pipeline?: PipelineStage[]
|
||||
repairActivity?: RepairActivity
|
||||
}
|
||||
raw?: Record<string, any>
|
||||
}
|
||||
@@ -288,6 +307,10 @@ export default function RequestTimelinePage() {
|
||||
(stage) => stage.id === 'available' && stage.state === 'active'
|
||||
)
|
||||
)
|
||||
const repairIsActive = Boolean(
|
||||
snapshot?.presentation?.repairActivity?.visible &&
|
||||
!['complete', 'attention'].includes(snapshot.presentation.repairActivity.state ?? '')
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!requestId) return
|
||||
@@ -367,12 +390,15 @@ export default function RequestTimelinePage() {
|
||||
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 () => {
|
||||
stopped = true
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}, [awaitingMediaIndex, requestId, router])
|
||||
}, [awaitingMediaIndex, repairIsActive, requestId, router])
|
||||
|
||||
const liveDownloadKey = useMemo(() => {
|
||||
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 statusMeaning = presentation.status?.meaning ?? snapshot.state_reason ?? 'Magent is checking this request.'
|
||||
const download = presentation.download
|
||||
const repairActivity = presentation.repairActivity
|
||||
const downloadVisible = Boolean(download?.visible)
|
||||
const nextStep = presentation.nextStep ?? {
|
||||
title: snapshot.actions[0]?.label ?? 'No action needed right now',
|
||||
@@ -753,6 +780,35 @@ export default function RequestTimelinePage() {
|
||||
)}
|
||||
</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">
|
||||
<div className="request-journey-heading">
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user