Show live feedback for remote request actions
Magent CI/CD / verify (push) Successful in 10m51s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 15s

This commit is contained in:
2026-08-30 21:23:20 +12:00
parent 02245d365e
commit 963506d098
9 changed files with 666 additions and 29 deletions
+119 -5
View File
@@ -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">