Files
Magent/frontend/app/requests/[id]/page.tsx
T
Assclaw 963506d098
Magent CI/CD / verify (push) Successful in 10m51s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 15s
Show live feedback for remote request actions
2026-08-30 21:23:20 +12:00

853 lines
33 KiB
TypeScript

'use client'
import Image from 'next/image'
import { useParams, useRouter } from 'next/navigation'
import { useEffect, useMemo, useState } from 'react'
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
type TimelineHop = {
service: string
status: string
details?: Record<string, any>
}
type RequestAction = {
id: string
label: string
risk: string
description?: string
requires_confirmation: boolean
}
type PipelineStage = {
id: string
label: string
state: 'complete' | 'active' | 'partial' | 'attention' | 'waiting' | string
summary: string
available?: number
missing?: number
total?: number
seasons?: Array<{ seasonNumber: number; available: number; missing: number; total: number }>
missingEpisodes?: Record<string, number[]>
actionIds?: string[]
visible?: boolean
torrents?: Array<Record<string, any>>
link?: string | null
}
type Snapshot = {
request_id: string
title: string
year?: number
request_type: string
state: string
state_reason?: string
timeline: TimelineHop[]
actions: RequestAction[]
artwork?: { poster_url?: string; backdrop_url?: string }
presentation?: {
status?: { label?: string; meaning?: string }
download?: {
visible?: boolean
state?: string
summary?: string
torrents?: Array<Record<string, any>>
lastSeenAt?: string | null
}
nextStep?: { title?: string; description?: string; actionIds?: string[] }
pipeline?: PipelineStage[]
}
raw?: Record<string, any>
}
type ReleaseOption = {
title?: string
indexer?: string
indexerId?: number
guid?: string
size?: number
seeders?: number
leechers?: number
protocol?: string
publishDate?: string
infoUrl?: string
downloadUrl?: string
magnetUrl?: string
fullSeason?: boolean
seasonNumber?: number
}
type SnapshotHistory = {
request_id: string
state: string
state_reason?: string
created_at: string
}
type ActionHistory = {
request_id: string
action_id: string
label: string
status: string
message?: string
created_at: string
}
type LiveDownloadProgress = {
request_id: string
state: string
summary: string
torrents: Array<Record<string, any>>
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') ?? ''
if (contentType.includes('application/json')) {
const payload = await response.json()
if (typeof payload?.detail === 'string' && payload.detail.trim()) return payload.detail
if (typeof payload?.message === 'string' && payload.message.trim()) return payload.message
} else {
const text = await response.text()
if (text.trim()) return text.trim()
}
} catch (error) {
console.error(error)
}
return fallback
}
const isSnapshotPayload = (value: unknown): value is Snapshot => {
if (!value || typeof value !== 'object') return false
const snapshot = value as Partial<Snapshot>
return (
typeof snapshot.request_id === 'string' &&
typeof snapshot.title === 'string' &&
typeof snapshot.request_type === 'string' &&
typeof snapshot.state === 'string' &&
Array.isArray(snapshot.timeline) &&
Array.isArray(snapshot.actions)
)
}
const formatBytes = (value?: number) => {
if (!value || Number.isNaN(value)) return 'Size unavailable'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let size = value
let index = 0
while (size >= 1024 && index < units.length - 1) {
size /= 1024
index += 1
}
return `${size.toFixed(1)} ${units[index]}`
}
const torrentProgress = (torrent: Record<string, any>) => {
const progress = Number(torrent.progress)
if (!Number.isNaN(progress) && progress >= 0 && progress <= 1) return Math.round(progress * 1000) / 10
const supplied = Number(torrent.progressPercent)
if (!Number.isNaN(supplied) && supplied >= 0 && supplied <= 100) return Math.round(supplied * 10) / 10
return null
}
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'
? 'complete'
: ['missing', 'error'].includes(live.state)
? 'attention'
: 'active'
const presentation = current.presentation ?? {}
const pipeline = (presentation.pipeline ?? fallbackPipeline(current)).map((stage) =>
stage.id === 'download'
? { ...stage, state: stageState, summary: live.summary, visible: true, torrents: live.torrents }
: stage
)
return {
...current,
presentation: {
...presentation,
download: {
...(presentation.download ?? {}),
visible: true,
state: live.state,
summary: live.summary,
torrents: live.torrents,
lastSeenAt: live.updated_at,
},
pipeline,
},
}
}
const fallbackStatusLabel = (state: string) => {
const labels: Record<string, string> = {
REQUESTED: 'Waiting for approval',
APPROVED: 'Approved — preparing collection',
NEEDS_ADD: 'Approved, but not yet in the library queue',
ADDED_TO_ARR: 'Added to library queue',
SEARCHING: 'Searching for a matching release',
GRABBED: 'Download queued',
DOWNLOADING: 'Download in progress',
IMPORTING: 'Preparing the collected media',
COMPLETED: 'Available to watch',
AVAILABLE: 'Available to watch',
FAILED: 'This request needs attention',
UNKNOWN: 'Checking request status',
}
return labels[state] ?? 'Checking request status'
}
const fallbackPipeline = (snapshot: Snapshot): PipelineStage[] => {
const approved = snapshot.state !== 'REQUESTED'
const complete = ['COMPLETED', 'AVAILABLE'].includes(snapshot.state)
return [
{ id: 'requested', label: 'Requested', state: 'complete', summary: 'Request received' },
{
id: 'approved',
label: 'Approved',
state: approved ? 'complete' : 'active',
summary: approved ? 'Approved for collection' : 'Waiting for approval',
},
{
id: 'library',
label: 'Library collection',
state: complete ? 'complete' : approved ? 'active' : 'waiting',
summary: complete ? 'Collection complete' : 'Waiting for collector information',
},
{ id: 'search', label: 'Release search', state: 'waiting', summary: 'Search state unavailable' },
{ id: 'download', label: 'Download', state: 'waiting', summary: 'No download attempt yet' },
{
id: 'available',
label: 'Available',
state: complete ? 'complete' : 'waiting',
summary: complete ? 'Available to watch' : 'Not available on the media server yet',
link: snapshot.raw?.jellyfin?.link,
},
]
}
const formatWhen = (value?: string | null) => {
if (!value) return 'Time unavailable'
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
}
export default function RequestTimelinePage() {
const params = useParams<{ id: string | string[] }>()
const requestId = Array.isArray(params?.id) ? params.id[0] : params?.id
const router = useRouter()
const [snapshot, setSnapshot] = useState<Snapshot | null>(null)
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
const [showDetails, setShowDetails] = useState(false)
const [actionMessage, setActionMessage] = useState<string | null>(null)
const [actionError, setActionError] = useState<string | null>(null)
const [busyAction, setBusyAction] = useState<string | null>(null)
const [releaseOptions, setReleaseOptions] = useState<ReleaseOption[]>([])
const [historySnapshots, setHistorySnapshots] = useState<SnapshotHistory[]>([])
const [historyActions, setHistoryActions] = useState<ActionHistory[]>([])
const [operationProgress, setOperationProgress] = useState<OperationProgress | null>(null)
useEffect(() => {
if (!requestId) return
const load = async () => {
setLoading(true)
setLoadError(null)
try {
if (!getToken()) {
router.push('/login')
return
}
const baseUrl = getApiBase()
const [snapshotResponse, historyResponse, actionsResponse] = await Promise.all([
authFetch(`${baseUrl}/requests/${requestId}/snapshot`),
authFetch(`${baseUrl}/requests/${requestId}/history?limit=10`),
authFetch(`${baseUrl}/requests/${requestId}/actions?limit=10`),
])
if ([snapshotResponse, historyResponse, actionsResponse].some((response) => response.status === 401)) {
clearToken()
router.push('/login')
return
}
if (!snapshotResponse.ok) {
throw new Error(await readApiError(snapshotResponse, 'Unable to load this request.'))
}
const snapshotData = await snapshotResponse.json()
if (!isSnapshotPayload(snapshotData)) throw new Error('Unable to load this request.')
setSnapshot(snapshotData)
if (historyResponse.ok) {
const historyData = await historyResponse.json()
if (Array.isArray(historyData.snapshots)) setHistorySnapshots(historyData.snapshots)
}
if (actionsResponse.ok) {
const actionsData = await actionsResponse.json()
if (Array.isArray(actionsData.actions)) setHistoryActions(actionsData.actions)
}
} catch (error) {
console.error(error)
setLoadError(error instanceof Error ? error.message : 'Unable to load this request.')
} finally {
setLoading(false)
}
}
void load()
}, [requestId, router])
useEffect(() => {
if (!getToken() || !requestId) return
let stopped = false
const refresh = async () => {
if (document.visibilityState === 'hidden') return
try {
const response = await authFetch(`${getApiBase()}/requests/${requestId}/snapshot`)
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
if (!response.ok) return
const payload = await response.json()
if (!stopped && isSnapshotPayload(payload)) setSnapshot(payload)
} catch (error) {
if (!stopped) console.error(error)
}
}
const timer = window.setInterval(() => void refresh(), 15_000)
return () => {
stopped = true
window.clearInterval(timer)
}
}, [requestId, router])
const liveDownloadKey = useMemo(() => {
const downloadStage = snapshot?.presentation?.pipeline?.find((stage) => stage.id === 'download')
if (!downloadStage?.visible || downloadStage.state !== 'active') return ''
return (downloadStage.torrents ?? [])
.filter((torrent) => (torrentProgress(torrent) ?? 100) < 100)
.map((torrent) => String(torrent.hash ?? torrent.name ?? 'download'))
.sort()
.join('|')
}, [snapshot])
useEffect(() => {
if (!getToken() || !requestId || !liveDownloadKey) return
let stopped = false
let timer: number | undefined
let controller: AbortController | null = null
const schedule = () => {
if (!stopped) timer = window.setTimeout(() => void refresh(), 2_000)
}
const refresh = async () => {
if (document.visibilityState === 'hidden') {
schedule()
return
}
controller = new AbortController()
try {
const response = await authFetch(`${getApiBase()}/requests/${requestId}/download-progress`, {
signal: controller.signal,
cache: 'no-store',
})
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
if (response.ok) {
const payload = await response.json() as LiveDownloadProgress
if (!stopped && Array.isArray(payload.torrents)) {
setSnapshot((current) => current ? mergeLiveDownload(current, payload) : current)
}
}
} catch (error) {
if (!stopped && !(error instanceof DOMException && error.name === 'AbortError')) console.error(error)
} finally {
controller = null
schedule()
}
}
timer = window.setTimeout(() => void refresh(), 750)
return () => {
stopped = true
if (timer !== undefined) window.clearTimeout(timer)
controller?.abort()
}
}, [liveDownloadKey, requestId, router])
const actionsById = useMemo(
() => new Map((snapshot?.actions ?? []).map((action) => [action.id, action])),
[snapshot?.actions]
)
if (loading) {
return (
<main className="card request-detail-page">
<div className="loading-center" role="status" aria-live="polite">
<div className="spinner" aria-hidden="true" />
<div className="loading-text">Building a clear request update</div>
</div>
</main>
)
}
if (loadError || !snapshot) {
return (
<main className="card request-detail-page">
<section className="request-error-state">
<span className="section-kicker">Request unavailable</span>
<h1>We could not load this request</h1>
<p>{loadError ?? 'The request API did not return a valid status.'}</p>
<div className="request-error-actions">
<button type="button" onClick={() => window.location.reload()}>Retry</button>
<button type="button" className="ghost-button" onClick={() => router.push('/')}>Back to requests</button>
</div>
</section>
</main>
)
}
const presentation = snapshot.presentation ?? {}
const pipeline = presentation.pipeline?.length ? presentation.pipeline : fallbackPipeline(snapshot)
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 downloadVisible = Boolean(download?.visible)
const nextStep = presentation.nextStep ?? {
title: snapshot.actions[0]?.label ?? 'No action needed right now',
description: snapshot.actions.length ? 'Choose an option below to continue.' : 'Magent will keep checking automatically.',
actionIds: snapshot.actions.slice(0, 2).map((action) => action.id),
}
const recommendedActions = (nextStep.actionIds ?? [])
.map((actionId) => actionsById.get(actionId))
.filter((action): action is RequestAction => Boolean(action))
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 trackedPost(
'Recheck request status',
`${getApiBase()}/requests/${snapshot.request_id}/actions/recheck`,
)
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
if (!response.ok) {
throw new Error(await readApiError(response, 'The request could not be rechecked.'))
}
const data = await response.json()
if (!isSnapshotPayload(data?.snapshot)) {
throw new Error('The request was checked, but Magent did not return a valid pipeline.')
}
setSnapshot(data.snapshot)
setActionMessage(data?.message ?? 'Request status rebuilt from live service data.')
} catch (error) {
console.error(error)
setActionError(error instanceof Error ? error.message : 'The request could not be rechecked.')
} finally {
setBusyAction(null)
}
}
const runAction = async (action: RequestAction) => {
if (action.requires_confirmation && !window.confirm(`Run “${action.label}”?`)) return
const actionPaths: Record<string, string> = {
search_releases: 'actions/search',
search_auto: 'actions/search_auto',
resume_torrent: 'actions/qbit/resume',
readd_to_arr: 'actions/readd',
}
const path = actionPaths[action.id]
if (!path) {
setActionError('This action is not connected yet.')
return
}
setBusyAction(action.id)
setActionError(null)
setActionMessage(null)
try {
const response = await trackedPost(
action.label,
`${getApiBase()}/requests/${snapshot.request_id}/${path}`
)
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
if (!response.ok) throw new Error(await readApiError(response, `${action.label} could not be completed.`))
const data = await response.json()
if (action.id === 'search_releases') {
const releases = Array.isArray(data.releases) ? data.releases : []
setReleaseOptions(releases)
setActionMessage(
releases.length
? `Found ${releases.length} possible release${releases.length === 1 ? '' : 's'}. Choose one below.`
: 'No matching releases were found. Magent will keep the request in the search stage.'
)
} else {
setActionMessage(data?.message ?? `${action.label} was started successfully.`)
}
} catch (error) {
console.error(error)
setActionError(error instanceof Error ? error.message : `${action.label} could not be completed.`)
} finally {
setBusyAction(null)
}
}
const downloadRelease = async (release: ReleaseOption) => {
if (!release.guid || !release.indexerId) {
setActionError('This release is missing the details needed to start it.')
return
}
const collector = snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr'
if (!window.confirm(`Send “${release.title ?? 'this release'}” through ${collector}?`)) return
setBusyAction(`grab:${release.guid}`)
setActionError(null)
try {
const response = await trackedPost(
`Send release through ${collector}`,
`${getApiBase()}/requests/${snapshot.request_id}/actions/grab`, {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(release),
})
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
if (!response.ok) throw new Error(await readApiError(response, 'The selected release could not be started.'))
const data = await response.json()
setActionMessage(data?.message ?? 'The selected release was queued for download.')
setReleaseOptions([])
} catch (error) {
console.error(error)
setActionError(error instanceof Error ? error.message : 'The selected release could not be started.')
} finally {
setBusyAction(null)
}
}
return (
<main className="card request-detail-page">
<div className="request-header">
<div className="request-header-main">
{resolvedPoster && (
<Image className="request-poster" src={resolvedPoster} alt={`${snapshot.title} poster`} width={90} height={135} sizes="90px" unoptimized />
)}
<div>
<span className="section-kicker">Request #{snapshot.request_id}</span>
<h1>{snapshot.title}</h1>
<div className="meta">{snapshot.request_type.toUpperCase()} {snapshot.year ?? ''}</div>
</div>
</div>
</div>
<section className="request-overview" aria-labelledby="request-status-heading">
<div className="request-overview-block request-overview-status">
<span className="request-overview-label" id="request-status-heading">Status</span>
<strong>{statusLabel}</strong>
</div>
<div className="request-overview-block">
<span className="request-overview-label">What this means</span>
<p>{statusMeaning}</p>
</div>
{downloadVisible && (
<div className="request-overview-block">
<span className="request-overview-label">Current download state</span>
<strong>{download?.summary ?? 'A download attempt has been observed.'}</strong>
{download?.lastSeenAt && !download?.torrents?.length && <small>Last observed {formatWhen(download.lastSeenAt)}</small>}
</div>
)}
<div className="request-overview-block request-next-step">
<span className="request-overview-label">Next step</span>
<strong>{nextStep.title}</strong>
<p>{nextStep.description}</p>
<div className="request-action-row">
{recommendedActions.map((action) => (
<button key={action.id} type="button" disabled={Boolean(busyAction)} onClick={() => void runAction(action)}>
{busyAction === action.id ? 'Working…' : action.label}
</button>
))}
<button
type="button"
className="request-recheck-button"
disabled={Boolean(busyAction)}
onClick={() => void recheckRequest()}
title="Recheck Seerr, the library collector, qBittorrent, and the media server"
>
{busyAction === 'recheck_pipeline' ? 'Rechecking…' : 'Recheck request'}
</button>
</div>
</div>
{(actionMessage || actionError) && (
<div className={`request-action-feedback ${actionError ? 'is-error' : 'is-success'}`} role="status">
{actionError ?? actionMessage}
</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 className="request-journey" aria-labelledby="request-journey-heading">
<div className="request-journey-heading">
<div>
<span className="section-kicker">Live collection path</span>
<h2 id="request-journey-heading">Where your request is now</h2>
</div>
<span className="request-live-indicator"><i />Live status</span>
</div>
<div className="request-stage-grid">
{pipeline.map((stage, index) => {
const stageActions = (stage.actionIds ?? [])
.map((actionId) => actionsById.get(actionId))
.filter((action): action is RequestAction => Boolean(action))
const content = (
<>
<div className="request-stage-topline">
<span className="request-stage-number">{String(index + 1).padStart(2, '0')}</span>
<span className={`request-stage-state state-${stage.state}`}>{stage.state}</span>
</div>
<h3>{stage.label}</h3>
<p>{stage.summary}</p>
{stage.id === 'library' && Boolean(stage.total) && (
<div className="request-availability-meter">
<div className="request-meter-copy"><span>{stage.available ?? 0} collected</span><span>{stage.missing ?? 0} missing</span></div>
<div
className="request-meter-track"
role="progressbar"
aria-label="Collection progress"
aria-valuemin={0}
aria-valuemax={stage.total ?? 0}
aria-valuenow={stage.available ?? 0}
>
<span style={{ width: `${Math.round(((stage.available ?? 0) / Math.max(stage.total ?? 1, 1)) * 100)}%` }} />
</div>
{stage.seasons?.map((season) => (
<div className="request-season-row" key={season.seasonNumber}><span>Season {season.seasonNumber}</span><span>{season.available} collected · {season.missing} missing</span></div>
))}
</div>
)}
{stage.id === 'library' && stage.missingEpisodes && Object.keys(stage.missingEpisodes).length > 0 && (
<div className="request-missing-list">
{Object.entries(stage.missingEpisodes).map(([season, episodes]) => (
<div key={season}><span>Missing from season {season}</span><strong>{episodes.map((episode) => `E${episode}`).join(', ')}</strong></div>
))}
</div>
)}
{stage.id === 'download' && stage.visible && stage.torrents?.map((torrent) => {
const progress = torrentProgress(torrent)
return (
<div className="request-torrent" key={torrent.hash ?? torrent.name}>
<div><strong>{torrent.name ?? 'Download'}</strong><span>{progress === null ? 'Progress unavailable' : formatProgress(progress)}</span></div>
{progress !== null && (
<div
className="request-meter-track is-live-download"
role="progressbar"
aria-label={`${torrent.name ?? 'Download'} progress`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={progress}
>
<span style={{ width: `${progress}%` }} />
</div>
)}
</div>
)
})}
{stageActions.length > 0 && (
<div className="request-stage-actions">
{stageActions.map((action) => (
<button key={action.id} type="button" disabled={Boolean(busyAction)} onClick={() => void runAction(action)}>{busyAction === action.id ? 'Working…' : action.label}</button>
))}
</div>
)}
</>
)
return stage.id === 'available' && stage.link ? (
<a className={`request-stage stage-${stage.state} is-link`} href={stage.link} target="_blank" rel="noreferrer" key={stage.id}>{content}<span className="request-stage-link">Open on media server </span></a>
) : (
<article className={`request-stage stage-${stage.state}`} key={stage.id}>{content}</article>
)
})}
</div>
{releaseOptions.length > 0 && (
<div className="request-release-picker">
<div className="request-release-heading"><div><span className="section-kicker">Manual selection through {snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr'}</span><h3>Choose a release</h3></div><button type="button" className="ghost-button" onClick={() => setReleaseOptions([])}>Close</button></div>
<div className="request-release-list">
{releaseOptions.map((release) => (
<div className="request-release" key={`${release.guid ?? release.title}`}>
<div><strong>{release.title ?? 'Unknown release'}</strong><span>{release.indexer ?? 'Unknown indexer'} · {release.seeders ?? 0} seeders · {formatBytes(release.size)}{release.fullSeason ? ` · Season ${release.seasonNumber ?? ''} pack` : ''}</span></div>
<button type="button" disabled={Boolean(busyAction) || !release.guid || !release.indexerId} onClick={() => void downloadRelease(release)}>{busyAction === `grab:${release.guid}` ? 'Sending…' : `Send through ${snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr'}`}</button>
</div>
))}
</div>
</div>
)}
</section>
<section className="request-advanced">
<button type="button" className="request-advanced-toggle" aria-expanded={showDetails} onClick={() => setShowDetails((current) => !current)}>
<span><strong>Advanced details</strong><small>Service diagnostics, status history and recorded actions</small></span>
<span>{showDetails ? 'Hide' : 'Show'}</span>
</button>
{showDetails && (
<div className="request-advanced-content">
<div className="request-diagnostics-grid">
{snapshot.timeline.map((hop, index) => (
<article className="request-diagnostic" key={`${hop.service}-${index}`}>
<div><strong>{hop.service}</strong><span>{hop.status}</span></div>
{hop.details && <pre>{JSON.stringify(hop.details, null, 2)}</pre>}
</article>
))}
</div>
<div className="history-grid">
<div className="summary-card">
<h3>Status changes</h3>
<ul>
{historySnapshots.length === 0 ? <li>No distinct status changes recorded yet.</li> : historySnapshots.map((entry) => (
<li key={`${entry.created_at}-${entry.state}`}><span>{fallbackStatusLabel(entry.state)}</span><small>{entry.state_reason ?? 'No additional detail.'} · {formatWhen(entry.created_at)}</small></li>
))}
</ul>
</div>
<div className="summary-card">
<h3>Recorded actions</h3>
<ul>
{historyActions.length === 0 ? <li>No actions have been run for this request.</li> : historyActions.map((entry) => (
<li key={`${entry.created_at}-${entry.action_id}`}><span>{entry.label}</span><small>{entry.message ?? entry.status} · {formatWhen(entry.created_at)}</small></li>
))}
</ul>
</div>
</div>
</div>
)}
</section>
</main>
)
}