Show live feedback for remote request actions
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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