Start Magent beta overhaul
This commit is contained in:
@@ -0,0 +1,598 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { authFetch, clearToken, getApiBase, getEventStreamToken, 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 supplied = Number(torrent.progressPercent)
|
||||
if (!Number.isNaN(supplied) && supplied >= 0 && supplied <= 100) return Math.round(supplied)
|
||||
const progress = Number(torrent.progress)
|
||||
if (!Number.isNaN(progress) && progress >= 0 && progress <= 1) return Math.round(progress * 100)
|
||||
return null
|
||||
}
|
||||
|
||||
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[]>([])
|
||||
|
||||
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
|
||||
const baseUrl = getApiBase()
|
||||
let closed = false
|
||||
let source: EventSource | null = null
|
||||
const connect = async () => {
|
||||
try {
|
||||
const streamToken = await getEventStreamToken()
|
||||
if (closed) return
|
||||
source = new EventSource(
|
||||
`${baseUrl}/events/requests/${encodeURIComponent(requestId)}/stream?stream_token=${encodeURIComponent(streamToken)}`
|
||||
)
|
||||
source.onmessage = (event) => {
|
||||
if (closed) return
|
||||
try {
|
||||
const payload = JSON.parse(event.data)
|
||||
if (payload?.type !== 'request_live' || String(payload.request_id ?? '') !== String(requestId)) return
|
||||
if (isSnapshotPayload(payload.snapshot)) setSnapshot(payload.snapshot)
|
||||
if (Array.isArray(payload.history)) setHistorySnapshots(payload.history)
|
||||
if (Array.isArray(payload.actions)) setHistoryActions(payload.actions)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!closed) console.error(error)
|
||||
}
|
||||
}
|
||||
void connect()
|
||||
return () => {
|
||||
closed = true
|
||||
source?.close()
|
||||
}
|
||||
}, [requestId])
|
||||
|
||||
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 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 authFetch(`${getApiBase()}/requests/${snapshot.request_id}/${path}`, { method: 'POST' })
|
||||
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
|
||||
}
|
||||
if (!window.confirm(`Download “${release.title ?? 'this release'}”?`)) return
|
||||
setBusyAction(`grab:${release.guid}`)
|
||||
setActionError(null)
|
||||
try {
|
||||
const response = await authFetch(`${getApiBase()}/requests/${snapshot.request_id}/actions/grab`, {
|
||||
method: 'POST',
|
||||
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>
|
||||
{recommendedActions.length > 0 && (
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(actionMessage || actionError) && (
|
||||
<div className={`request-action-feedback ${actionError ? 'is-error' : 'is-success'}`} role="status">
|
||||
{actionError ?? actionMessage}
|
||||
</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' : `${progress}% complete`}</span></div>
|
||||
{progress !== null && <div className="request-meter-track"><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</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)}</span></div>
|
||||
<button type="button" disabled={Boolean(busyAction) || !release.guid || !release.indexerId} onClick={() => void downloadRelease(release)}>{busyAction === `grab:${release.guid}` ? 'Starting…' : 'Download this release'}</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user