-
-
Notifications
You must be signed in to change notification settings - Fork 131
feat: improve admin queue diagnostics and recovery ui #571
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,153 @@ | ||||||||||||||||||||||||||||||||||||||||
| import React, { useState } from 'react'; | ||||||||||||||||||||||||||||||||||||||||
| import { HiOutlineRefresh } from 'react-icons/hi'; | ||||||||||||||||||||||||||||||||||||||||
| import { MdRestartAlt } from 'react-icons/md'; | ||||||||||||||||||||||||||||||||||||||||
| import { useFirestore, useFirestoreCollectionData, useUser } from 'reactfire'; | ||||||||||||||||||||||||||||||||||||||||
| import { SimpleAuthCheck } from '@site/src/components/auth/safe-auth-check'; | ||||||||||||||||||||||||||||||||||||||||
| import config from '@site/src/core/config'; | ||||||||||||||||||||||||||||||||||||||||
| import { useNotification } from '@site/src/core/hooks/use-notification'; | ||||||||||||||||||||||||||||||||||||||||
| import Spinner from '@site/src/components/molecules/spinner'; | ||||||||||||||||||||||||||||||||||||||||
| import Tooltip from '@site/src/components/molecules/tooltip/tooltip'; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| interface Props { | ||||||||||||||||||||||||||||||||||||||||
| ciJobId: string; | ||||||||||||||||||||||||||||||||||||||||
| status: string; | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| type BuildRecord = { | ||||||||||||||||||||||||||||||||||||||||
| buildId: string; | ||||||||||||||||||||||||||||||||||||||||
| relatedJobId: string; | ||||||||||||||||||||||||||||||||||||||||
| status: string; | ||||||||||||||||||||||||||||||||||||||||
| meta?: { | ||||||||||||||||||||||||||||||||||||||||
| failureCount?: number; | ||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| const buttonStyle: React.CSSProperties = { | ||||||||||||||||||||||||||||||||||||||||
| padding: 0, | ||||||||||||||||||||||||||||||||||||||||
| border: 0, | ||||||||||||||||||||||||||||||||||||||||
| outline: 0, | ||||||||||||||||||||||||||||||||||||||||
| cursor: 'pointer', | ||||||||||||||||||||||||||||||||||||||||
| background: 'transparent', | ||||||||||||||||||||||||||||||||||||||||
| display: 'inline-flex', | ||||||||||||||||||||||||||||||||||||||||
| alignItems: 'center', | ||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| const ImageJobAdminActions = ({ ciJobId, status }: Props) => { | ||||||||||||||||||||||||||||||||||||||||
| const firestore = useFirestore(); | ||||||||||||||||||||||||||||||||||||||||
| const { data: user } = useUser(); | ||||||||||||||||||||||||||||||||||||||||
| const notify = useNotification(); | ||||||||||||||||||||||||||||||||||||||||
| const [runningAction, setRunningAction] = useState<'reset' | 'retry' | null>(null); | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| const ciBuilds = firestore.collection('ciBuilds').where('relatedJobId', '==', ciJobId); | ||||||||||||||||||||||||||||||||||||||||
| const { status: buildStatus, data = [] } = useFirestoreCollectionData<BuildRecord>(ciBuilds); | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| if (status !== 'failed' || buildStatus === 'loading') return null; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| const failedBuilds = data.filter((build) => build.status === 'failed'); | ||||||||||||||||||||||||||||||||||||||||
| if (failedBuilds.length === 0) return null; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| const maxedOutBuilds = failedBuilds.filter((build) => (build.meta?.failureCount || 0) >= 15); | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| const callEndpoint = async (endpoint: string, payload: object) => { | ||||||||||||||||||||||||||||||||||||||||
| if (!user) { | ||||||||||||||||||||||||||||||||||||||||
| throw new Error('User not authenticated'); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| const token = await user.getIdToken(); | ||||||||||||||||||||||||||||||||||||||||
| const response = await fetch(`${config.backendUrl}/${endpoint}`, { | ||||||||||||||||||||||||||||||||||||||||
| headers: { | ||||||||||||||||||||||||||||||||||||||||
| Authorization: `Bearer ${token}`, | ||||||||||||||||||||||||||||||||||||||||
| 'Content-Type': 'application/json', | ||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||
| mode: 'cors', | ||||||||||||||||||||||||||||||||||||||||
| method: 'POST', | ||||||||||||||||||||||||||||||||||||||||
| body: JSON.stringify(payload), | ||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| const body = await response.json(); | ||||||||||||||||||||||||||||||||||||||||
| if (!response.ok) { | ||||||||||||||||||||||||||||||||||||||||
| const detail = body.error ? `${body.message}: ${body.error}` : body.message; | ||||||||||||||||||||||||||||||||||||||||
| throw new Error(detail || `Request failed (${response.status})`); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+67
to
+71
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Parse JSON defensively so non-JSON error bodies don't mask the real failure.
🛡️ Proposed fix- const body = await response.json();
- if (!response.ok) {
- const detail = body.error ? `${body.message}: ${body.error}` : body.message;
- throw new Error(detail || `Request failed (${response.status})`);
- }
- return body;
+ const rawText = await response.text();
+ let body: { message?: string; error?: string; [key: string]: unknown } = {};
+ if (rawText) {
+ try {
+ body = JSON.parse(rawText);
+ } catch {
+ // Non-JSON response; fall through with empty body so we surface the status code.
+ }
+ }
+ if (!response.ok) {
+ const detail = body.error ? `${body.message}: ${body.error}` : body.message;
+ throw new Error(detail || `Request failed (${response.status})`);
+ }
+ return body;📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||
| return body; | ||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| const runAction = async ( | ||||||||||||||||||||||||||||||||||||||||
| action: 'reset' | 'retry', | ||||||||||||||||||||||||||||||||||||||||
| builds: BuildRecord[], | ||||||||||||||||||||||||||||||||||||||||
| endpoint: 'resetFailedBuilds' | 'retryBuild', | ||||||||||||||||||||||||||||||||||||||||
| ) => { | ||||||||||||||||||||||||||||||||||||||||
| if (builds.length === 0) return; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| setRunningAction(action); | ||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||
| let succeeded = 0; | ||||||||||||||||||||||||||||||||||||||||
| let failed = 0; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| for (const build of builds) { | ||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||
| const payload = | ||||||||||||||||||||||||||||||||||||||||
| endpoint === 'retryBuild' | ||||||||||||||||||||||||||||||||||||||||
| ? { buildId: build.buildId, relatedJobId: build.relatedJobId } | ||||||||||||||||||||||||||||||||||||||||
| : { buildId: build.buildId }; | ||||||||||||||||||||||||||||||||||||||||
| // Sequential calls avoid hammering the backend for a single image row action. | ||||||||||||||||||||||||||||||||||||||||
| await callEndpoint(endpoint, payload); | ||||||||||||||||||||||||||||||||||||||||
| succeeded += 1; | ||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||
| failed += 1; | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| if (failed > 0) { | ||||||||||||||||||||||||||||||||||||||||
| notify.error( | ||||||||||||||||||||||||||||||||||||||||
| `${action === 'retry' ? 'Retried' : 'Reset'} ${succeeded}/${builds.length} builds. ${failed} failed.`, | ||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||
| notify.success( | ||||||||||||||||||||||||||||||||||||||||
| `${action === 'retry' ? 'Retried' : 'Reset'} ${succeeded} build${succeeded === 1 ? '' : 's'} for ${ciJobId}.`, | ||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| } finally { | ||||||||||||||||||||||||||||||||||||||||
| setRunningAction(null); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||||||||||||
| <SimpleAuthCheck fallback={<span />} requiredClaims={{ admin: true }}> | ||||||||||||||||||||||||||||||||||||||||
| <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, marginLeft: 8 }}> | ||||||||||||||||||||||||||||||||||||||||
| {maxedOutBuilds.length > 0 && ( | ||||||||||||||||||||||||||||||||||||||||
| <Tooltip | ||||||||||||||||||||||||||||||||||||||||
| content={`Reset ${maxedOutBuilds.length} maxed-out failed build${maxedOutBuilds.length === 1 ? '' : 's'} so Ingeminator can retry them.`} | ||||||||||||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||||||||||||
| <button | ||||||||||||||||||||||||||||||||||||||||
| type="button" | ||||||||||||||||||||||||||||||||||||||||
| onClick={() => { | ||||||||||||||||||||||||||||||||||||||||
| void runAction('reset', maxedOutBuilds, 'resetFailedBuilds'); | ||||||||||||||||||||||||||||||||||||||||
| }} | ||||||||||||||||||||||||||||||||||||||||
| style={buttonStyle} | ||||||||||||||||||||||||||||||||||||||||
| disabled={runningAction !== null} | ||||||||||||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||||||||||||
| <MdRestartAlt color={runningAction === 'reset' ? 'orange' : '#b45309'} /> | ||||||||||||||||||||||||||||||||||||||||
| </button> | ||||||||||||||||||||||||||||||||||||||||
| </Tooltip> | ||||||||||||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||||||||||||
| <Tooltip | ||||||||||||||||||||||||||||||||||||||||
| content={`Retry ${failedBuilds.length} failed build${failedBuilds.length === 1 ? '' : 's'} for this image.`} | ||||||||||||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||||||||||||
| <button | ||||||||||||||||||||||||||||||||||||||||
| type="button" | ||||||||||||||||||||||||||||||||||||||||
| onClick={() => { | ||||||||||||||||||||||||||||||||||||||||
| void runAction('retry', failedBuilds, 'retryBuild'); | ||||||||||||||||||||||||||||||||||||||||
| }} | ||||||||||||||||||||||||||||||||||||||||
| style={buttonStyle} | ||||||||||||||||||||||||||||||||||||||||
| disabled={runningAction !== null} | ||||||||||||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||||||||||||
| {runningAction === 'retry' ? <Spinner type="spin" /> : <HiOutlineRefresh color="red" />} | ||||||||||||||||||||||||||||||||||||||||
| </button> | ||||||||||||||||||||||||||||||||||||||||
| </Tooltip> | ||||||||||||||||||||||||||||||||||||||||
| </span> | ||||||||||||||||||||||||||||||||||||||||
| </SimpleAuthCheck> | ||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| export default ImageJobAdminActions; | ||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Avoid subscribing to Firestore for every non-failed job row.
useFirestoreCollectionDatais invoked unconditionally, so a per-rowciBuildslistener (filtered byrelatedJobId) is created for everyUnityVersionrendered — including jobs that arecompleted,scheduled,inProgress,superseded, etc. On a docs page that renders many image versions, this fans out into N live Firestore listeners just to discard the data at thestatus !== 'failed'gate one line below. Gate the component before the hook runs by splitting the inner logic into a separate component.♻️ Proposed fix: gate the Firestore subscription on status
🤖 Prompt for AI Agents