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
+73
View File
@@ -2012,6 +2012,76 @@ button:disabled,
}
.request-action-feedback.is-error { color: var(--request-red); background: rgba(255, 86, 113, 0.08); }
.request-operation-progress {
grid-column: 1 / -1;
display: grid;
gap: 14px;
padding: 16px 18px;
border-top: 1px solid var(--ops-line-soft);
background: rgba(5, 9, 20, 0.42);
}
.request-operation-progress.is-running { background: rgba(14, 165, 233, 0.055); }
.request-operation-progress.is-error { background: rgba(255, 86, 113, 0.055); }
.request-operation-heading,
.request-operation-heading-actions,
.request-operation-event { display: flex; align-items: center; gap: 12px; }
.request-operation-heading { justify-content: space-between; }
.request-operation-heading > div:first-child { display: grid; gap: 4px; }
.request-operation-heading > div:first-child > strong { color: var(--ops-text); font-size: 0.94rem; }
.request-operation-heading-actions { color: var(--ops-muted); font-size: 0.7rem; }
.request-operation-heading-actions button {
min-height: 30px;
padding: 5px 9px;
border: 1px solid var(--ops-line);
background: rgba(255, 255, 255, 0.035);
color: var(--ops-text);
font-size: 0.7rem;
}
.request-operation-status {
padding: 5px 8px;
border: 1px solid var(--ops-line);
border-radius: 999px;
color: var(--ops-muted);
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 0.62rem;
font-weight: 750;
text-transform: uppercase;
}
.request-operation-status.is-running { border-color: rgba(126, 215, 255, 0.36); color: var(--ops-cyan); }
.request-operation-status.is-complete { border-color: rgba(72, 224, 178, 0.3); color: var(--request-green); }
.request-operation-status.is-error { border-color: rgba(255, 86, 113, 0.34); color: var(--request-red); }
.request-operation-events { display: grid; gap: 7px; }
.request-operation-event {
min-width: 0;
padding: 10px 12px;
border: 1px solid var(--ops-line-soft);
border-radius: var(--ops-radius);
background: rgba(255, 255, 255, 0.022);
}
.request-operation-event > i {
flex: 0 0 auto;
width: 9px;
height: 9px;
border: 2px solid currentColor;
border-radius: 50%;
color: var(--ops-muted);
}
.request-operation-event.is-active > i {
color: var(--ops-cyan);
box-shadow: 0 0 12px currentColor;
animation: request-operation-pulse 1.15s ease-in-out infinite;
}
.request-operation-event.is-complete > i { color: var(--request-green); background: currentColor; }
.request-operation-event.is-error > i { color: var(--request-red); background: currentColor; }
.request-operation-event > div { display: grid; gap: 2px; min-width: 0; }
.request-operation-event strong { color: var(--ops-text); font-size: 0.75rem; }
.request-operation-event span { color: var(--ops-muted); font-size: 0.75rem; }
.request-operation-event small { margin-left: auto; color: var(--ops-muted); font-size: 0.66rem; white-space: nowrap; }
@keyframes request-operation-pulse {
0%, 100% { opacity: 0.5; transform: scale(0.8); }
50% { opacity: 1; transform: scale(1); }
}
.request-journey { display: grid; gap: 18px; padding: 20px; }
.request-journey-heading,
.request-release-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
@@ -2138,4 +2208,7 @@ button:disabled,
.request-stage-actions { display: grid; }
.request-action-row button,
.request-release button { width: 100%; }
.request-operation-heading { align-items: flex-start; flex-direction: column; }
.request-operation-heading-actions { width: 100%; flex-wrap: wrap; }
.request-operation-event { align-items: flex-start; }
}
+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">