Show live feedback for remote request actions
This commit is contained in:
@@ -101,6 +101,23 @@ type LiveDownloadProgress = {
|
||||
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') ?? ''
|
||||
@@ -153,6 +170,12 @@ const torrentProgress = (torrent: Record<string, any>) => {
|
||||
|
||||
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'
|
||||
@@ -251,6 +274,7 @@ export default function RequestTimelinePage() {
|
||||
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
|
||||
@@ -427,15 +451,64 @@ export default function RequestTimelinePage() {
|
||||
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 authFetch(
|
||||
const response = await trackedPost(
|
||||
'Recheck request status',
|
||||
`${getApiBase()}/requests/${snapshot.request_id}/actions/recheck`,
|
||||
{ method: 'POST' }
|
||||
)
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
@@ -476,7 +549,10 @@ export default function RequestTimelinePage() {
|
||||
setActionError(null)
|
||||
setActionMessage(null)
|
||||
try {
|
||||
const response = await authFetch(`${getApiBase()}/requests/${snapshot.request_id}/${path}`, { method: 'POST' })
|
||||
const response = await trackedPost(
|
||||
action.label,
|
||||
`${getApiBase()}/requests/${snapshot.request_id}/${path}`
|
||||
)
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
@@ -513,8 +589,9 @@ export default function RequestTimelinePage() {
|
||||
setBusyAction(`grab:${release.guid}`)
|
||||
setActionError(null)
|
||||
try {
|
||||
const response = await authFetch(`${getApiBase()}/requests/${snapshot.request_id}/actions/grab`, {
|
||||
method: 'POST',
|
||||
const response = await trackedPost(
|
||||
`Send release through ${collector}`,
|
||||
`${getApiBase()}/requests/${snapshot.request_id}/actions/grab`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(release),
|
||||
})
|
||||
@@ -592,6 +669,43 @@ export default function RequestTimelinePage() {
|
||||
{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">
|
||||
|
||||
Reference in New Issue
Block a user