From c88c0b090b1666b425dc22d35d827e1dcbfc065e Mon Sep 17 00:00:00 2001 From: frostebite Date: Sun, 29 Mar 2026 02:20:48 +0100 Subject: [PATCH 1/3] Add admin dashboard, bulk retry, filters, timeline to versions page - Build status dashboard: live counts of published/in-progress/failed/stuck builds - Bulk retry: checkbox selection on failed builds with bulk reset and retry actions - Build logs viewer: failure reason shown inline, expandable build log in details - Filter/search: search by version/platform/OS, filter by status (published, failed, stuck) - Auto-refresh: Firestore live listeners with green "Live" indicator - Build timeline: timestamps for created, last started, last failure, published dates Co-Authored-By: Claude Opus 4.6 --- .../docs/versions/build-status-dashboard.tsx | 100 +++++++++ .../docs/versions/builds/build-row.tsx | 149 +++++++++++++- .../docs/versions/builds/builds.tsx | 193 ++++++++++++++++-- .../docs/versions/image-versions.tsx | 74 ++++++- .../docs/versions/unity-versions.tsx | 60 ++++-- 5 files changed, 537 insertions(+), 39 deletions(-) create mode 100644 src/components/docs/versions/build-status-dashboard.tsx diff --git a/src/components/docs/versions/build-status-dashboard.tsx b/src/components/docs/versions/build-status-dashboard.tsx new file mode 100644 index 00000000..cc224ef0 --- /dev/null +++ b/src/components/docs/versions/build-status-dashboard.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { useFirestore, useFirestoreCollectionData } from 'reactfire'; +import Spinner from '@site/src/components/molecules/spinner'; + +interface Props { + selectedRepoVersion: string | undefined; +} + +const BuildStatusDashboard = ({ selectedRepoVersion }: Props) => { + if (!selectedRepoVersion) return null; + + const ciBuilds = useFirestore() + .collection('ciBuilds') + .where('buildInfo.repoVersion', '==', selectedRepoVersion); + + const { status, data } = useFirestoreCollectionData<{ [key: string]: any }>(ciBuilds); + + if (status === 'loading') { + return ( +
+ Loading build stats... +
+ ); + } + + const builds = data || []; + const started = builds.filter((b) => b.status === 'started').length; + const failed = builds.filter((b) => b.status === 'failed').length; + const published = builds.filter((b) => b.status === 'published').length; + const maxedOut = builds.filter( + (b) => b.status === 'failed' && (b.meta?.failureCount || 0) >= 15, + ).length; + const total = builds.length; + + const statStyle = (color: string): React.CSSProperties => ({ + display: 'inline-flex', + alignItems: 'center', + gap: 6, + padding: '4px 12px', + borderRadius: 6, + border: `1px solid ${color}33`, + background: `${color}11`, + fontSize: '0.85em', + fontWeight: 500, + }); + + return ( +
+ + Total: {total} + + + Published: {published} + + + In progress: {started} + + + Failed: {failed} + + {maxedOut > 0 && ( + + Stuck (15+): {maxedOut} + + )} + + + Live + +
+ ); +}; + +export default BuildStatusDashboard; diff --git a/src/components/docs/versions/builds/build-row.tsx b/src/components/docs/versions/builds/build-row.tsx index 738d422e..e9b50120 100644 --- a/src/components/docs/versions/builds/build-row.tsx +++ b/src/components/docs/versions/builds/build-row.tsx @@ -2,26 +2,40 @@ import React, { useState } from 'react'; import DockerImageLinkOrRetryButton, { type Record, } from '@site/src/components/docs/versions/docker-image-link-or-retry-button'; +import { SimpleAuthCheck } from '@site/src/components/auth/safe-auth-check'; import Spinner from '@site/src/components/molecules/spinner'; import Tooltip from '@site/src/components/molecules/tooltip/tooltip'; import styles from './builds.module.scss'; const mapBuildStatusToIcon = { started: , - failed: '⚠', - published: '✅', + failed: '\u26A0', + published: '\u2705', }; type Props = { children: React.JSX.Element | React.JSX.Element[]; build: Record; + selected: boolean; + onToggleSelect: () => void; }; const CopyToClipboard = (copyString: string) => { navigator.clipboard.writeText(copyString); }; -export default function BuildRow({ children, build }: Props) { +const formatTimestamp = (ts: any): string => { + if (!ts) return ''; + const date = ts.seconds ? new Date(ts.seconds * 1000) : new Date(ts); + return date.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +}; + +export default function BuildRow({ children, build, selected, onToggleSelect }: Props) { const [expanded, setExpanded] = useState(false); const [toolbarContent, setToolbarContent] = useState('Click to copy'); @@ -32,8 +46,8 @@ export default function BuildRow({ children, build }: Props) { case 'started': return ; case 'failed': { - const failureCount = build.meta?.failureCount || 0; - const label = failureCount >= 15 ? `${icon} (${failureCount}/15)` : icon; + const count = build.meta?.failureCount || 0; + const label = count >= 15 ? `${icon} (${count}/15)` : icon; return {label}; } case 'published': @@ -43,9 +57,49 @@ export default function BuildRow({ children, build }: Props) { } }; + // Build timeline data + const meta = (build.meta || {}) as { [key: string]: any }; + const { addedDate } = build; + const { lastBuildStart, lastBuildFailure, publishedDate, failureCount = 0 } = meta; + + const timelineItems: { label: string; time: string; color: string }[] = []; + if (addedDate) + timelineItems.push({ label: 'Created', time: formatTimestamp(addedDate), color: '#666' }); + if (lastBuildStart) + timelineItems.push({ + label: 'Last started', + time: formatTimestamp(lastBuildStart), + color: '#3b82f6', + }); + if (lastBuildFailure) + timelineItems.push({ + label: `Last failure (#${failureCount})`, + time: formatTimestamp(lastBuildFailure), + color: '#ef4444', + }); + if (publishedDate) + timelineItems.push({ + label: 'Published', + time: formatTimestamp(publishedDate), + color: '#22c55e', + }); + return ( <> + } requiredClaims={{ admin: true }}> + + {build.status === 'failed' && ( + + )} + + setExpanded(!expanded)} className="text-center select-none cursor-pointer text-2xl" @@ -76,9 +130,92 @@ export default function BuildRow({ children, build }: Props) { {build.buildInfo.baseOs} {build.buildInfo.targetPlatform} + {/* Inline failure reason row (visible without expanding) */} + {build.status === 'failed' && build.failure?.reason && !expanded && ( + + + + + {build.failure.reason.slice(0, 200)} + {build.failure.reason.length > 200 ? '...' : ''} + + + )} {expanded && ( - {children} + + {/* Build timeline */} + {timelineItems.length > 0 && ( +
+ {timelineItems.map((item) => ( + + + {item.label}: {item.time} + + ))} +
+ )} + {/* Failure log viewer */} + {build.status === 'failed' && build.failure && ( +
+ Failure reason: {build.failure.reason || 'Unknown'} + {build.failure.log && ( +
+ Build log +
+                      {build.failure.log}
+                    
+
+ )} +
+ )} + {children} + )} diff --git a/src/components/docs/versions/builds/builds.tsx b/src/components/docs/versions/builds/builds.tsx index 64e2f9ef..b7140dc8 100644 --- a/src/components/docs/versions/builds/builds.tsx +++ b/src/components/docs/versions/builds/builds.tsx @@ -1,6 +1,10 @@ -import React from 'react'; -import { useFirestore, useFirestoreCollectionData } from 'reactfire'; +import React, { useState } from 'react'; +import { useFirestore, useFirestoreCollectionData, useUser } from 'reactfire'; import BuildFailureDetails from '@site/src/components/docs/versions/builds/build-failure-details'; +import { SimpleAuthCheck } from '@site/src/components/auth/safe-auth-check'; +import { useNotification } from '@site/src/core/hooks/use-notification'; +import config from '@site/src/core/config'; +import Spinner from '@site/src/components/molecules/spinner'; import styles from './builds.module.scss'; import BuildRow from './build-row'; import { Record } from '../docker-image-link-or-retry-button'; @@ -18,8 +22,112 @@ interface Props { editorVersionInfo; } +const BulkActions = ({ selectedIds, onClear }: { selectedIds: string[]; onClear: () => void }) => { + const [running, setRunning] = useState(false); + const notify = useNotification(); + const { data: user } = useUser(); + + const callEndpoint = async (endpoint: string, payload: object) => { + 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})`); + } + return body; + }; + + const runBulkAction = async (endpoint: string, label: string) => { + setRunning(true); + try { + let succeeded = 0; + let failed = 0; + const batchSize = 5; + for (let offset = 0; offset < selectedIds.length; offset += batchSize) { + const batch = selectedIds.slice(offset, offset + batchSize); + const results = await Promise.allSettled( + batch.map((buildId) => callEndpoint(endpoint, { buildId })), + ); + succeeded += results.filter((r) => r.status === 'fulfilled').length; + failed += results.filter((r) => r.status === 'rejected').length; + } + if (failed > 0) { + notify.error(`${label} ${succeeded}/${selectedIds.length}. ${failed} failed.`); + } else { + notify.success(`${label} ${succeeded} of ${selectedIds.length} builds`); + } + onClear(); + } finally { + setRunning(false); + } + }; + + if (selectedIds.length === 0) return null; + + const barStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: 8, + padding: '6px 12px', + marginBottom: 8, + borderRadius: 6, + border: '1px solid #3b82f633', + background: '#3b82f611', + fontSize: '0.85em', + }; + + const buttonStyle: React.CSSProperties = { + padding: '2px 10px', + borderRadius: 4, + border: '1px solid #ccc', + background: 'transparent', + cursor: running ? 'wait' : 'pointer', + fontSize: '0.85em', + }; + + return ( +
+ {selectedIds.length} selected + + + +
+ ); +}; + const Builds = ({ ciJobId, repoVersionInfo, editorVersionInfo }: Props) => { const loading =

Fetching builds...

; + const [selectedBuilds, setSelectedBuilds] = useState>(new Set()); const ciBuilds = useFirestore().collection('ciBuilds').where('relatedJobId', '==', ciJobId); @@ -30,6 +138,24 @@ const Builds = ({ ciJobId, repoVersionInfo, editorVersionInfo }: Props) => { return loading; } + const toggleBuild = (buildId: string) => { + setSelectedBuilds((previous) => { + const next = new Set(previous); + if (next.has(buildId)) next.delete(buildId); + else next.add(buildId); + return next; + }); + }; + + const toggleAll = () => { + const failedBuilds = data.filter((b) => b.status === 'failed'); + if (selectedBuilds.size === failedBuilds.length && failedBuilds.length > 0) { + setSelectedBuilds(new Set()); + } else { + setSelectedBuilds(new Set(failedBuilds.map((b) => b.buildId))); + } + }; + const expandable = { expandedRowRender: (record) => ( { ), }; + const failedCount = data.filter((b) => b.status === 'failed').length; + return ( - - - - - - - - - - {data.map((build: Record) => ( - {expandable.expandedRowRender(build)} - ))} -
StatusBuild IDImage typeOSTarget Platform
+ <> + } requiredClaims={{ admin: true }}> + setSelectedBuilds(new Set())} + /> + + + + + } + requiredClaims={{ admin: true }} + > + + + + + + + + + + + + {data.map((build: Record) => ( + toggleBuild(build.buildId)} + > + {expandable.expandedRowRender(build)} + + ))} + +
+ {failedCount > 0 && ( + 0} + onChange={toggleAll} + title="Select all failed builds" + style={{ cursor: 'pointer' }} + /> + )} + StatusBuild IDImage typeOSTarget Platform
+ ); }; diff --git a/src/components/docs/versions/image-versions.tsx b/src/components/docs/versions/image-versions.tsx index 711f5bca..b14a3cba 100644 --- a/src/components/docs/versions/image-versions.tsx +++ b/src/components/docs/versions/image-versions.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import SignInSignOutButton from '@site/src/components/auth/sign-in-sign-out-button'; import CleanUpStuckBuildsButton from './clean-up-stuck-builds-button'; import ResetAllFailedBuildsButton from './reset-all-failed-builds-button'; +import BuildStatusDashboard from './build-status-dashboard'; import UnityVersions from './unity-versions'; import styles from './unity-version.module.scss'; @@ -9,8 +10,33 @@ interface Props { versions: { [key: string]: any }[]; } +export type StatusFilter = 'all' | 'started' | 'failed' | 'published' | 'stuck'; + const ImageVersions = ({ versions }: Props) => { const [selectedVersion, setSelectedVersion] = useState(versions[0].NO_ID_FIELD); + const [searchQuery, setSearchQuery] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + + const filterBarStyle: React.CSSProperties = { + display: 'flex', + flexWrap: 'wrap', + gap: 8, + alignItems: 'center', + padding: '8px 0', + }; + + const selectStyle: React.CSSProperties = { + padding: '4px 8px', + borderRadius: 4, + border: '1px solid #ccc', + background: 'transparent', + fontSize: '0.85em', + }; + + const inputStyle: React.CSSProperties = { + ...selectStyle, + minWidth: 220, + }; return (
@@ -37,7 +63,53 @@ const ImageVersions = ({ versions }: Props) => { - + + +
+ setSearchQuery(event.target.value)} + style={inputStyle} + /> + + {(searchQuery || statusFilter !== 'all') && ( + + )} +
+ +
); }; diff --git a/src/components/docs/versions/unity-versions.tsx b/src/components/docs/versions/unity-versions.tsx index 87dfee3b..cc8a068e 100644 --- a/src/components/docs/versions/unity-versions.tsx +++ b/src/components/docs/versions/unity-versions.tsx @@ -2,13 +2,15 @@ import React from 'react'; import { useFirestore, useFirestoreCollectionData } from 'reactfire'; import UnityVersion from '@site/src/components/docs/versions/unity-version'; import MajorEditorVersion from './major-editor-version'; +import type { StatusFilter } from './image-versions'; interface Props { selectedRepoVersion: string | undefined; - // setIsLoading: Dispatch>; + searchQuery: string; + statusFilter: StatusFilter; } -const UnityVersions = ({ selectedRepoVersion }: Props) => { +const UnityVersions = ({ selectedRepoVersion, searchQuery, statusFilter }: Props) => { if (!selectedRepoVersion) return null; const ciJobs = useFirestore() @@ -22,34 +24,51 @@ const UnityVersions = ({ selectedRepoVersion }: Props) => { const isLoading = status === 'loading'; const loading =

Fetching versions...

; - const failures = isLoading ? [] : data.filter((version) => version.status === 'failed'); + + // Apply filters + let filtered = isLoading ? [] : [...data]; + + // Status filter + if (statusFilter !== 'all') { + filtered = filtered.filter((version) => { + if (statusFilter === 'stuck') { + return version.status === 'failed'; + } + return version.status === statusFilter; + }); + } + + // Search filter (matches against the job ID which contains version info) + if (searchQuery) { + const q = searchQuery.toLowerCase(); + filtered = filtered.filter((version) => { + const id = (version.NO_ID_FIELD || '').toLowerCase(); + const editor = version.editorVersionInfo + ? `${version.editorVersionInfo.major}.${version.editorVersionInfo.minor}.${version.editorVersionInfo.patch}` + : ''; + return id.toLowerCase().includes(q) || editor.toLowerCase().includes(q); + }); + } + + const failures = filtered.filter((version) => version.status === 'failed'); const versions = {}; - if (data) { + if (filtered.length > 0) { // Sorting the data based on the version numbers to maintain the version order - data.sort((a, b) => { + filtered.sort((a, b) => { const infoA = a.editorVersionInfo; const infoB = b.editorVersionInfo; - // Using major , minor and patch to compare the two numbers const { major: majorA, minor: minorA, patch: patchA } = infoA; const { major: majorB, minor: minorB, patch: patchB } = infoB; - // First checking for major version. if (majorA > majorB) return -1; if (majorA < majorB) return 1; - // If major version is equal check for minor version. if (minorA > minorB) return -1; if (minorA < minorB) return 1; - // If major and minor both are equal check the patch version. - - // For patch assuming "f" is present and splitting based on that.(Can use regex to split also). - - // Calculating a patchNumber which is the priority offset based sum of the numbers in - // the array formed after split. The offset is used to correctly get the priority. let patchANumber = 0; for (const [index, currentValue] of patchA.split('f').entries()) { patchANumber += 10 ** (9 - 3 * index) * Number.parseInt(currentValue, 10); @@ -63,8 +82,7 @@ const UnityVersions = ({ selectedRepoVersion }: Props) => { }); // Sort versions into organized array by major version number - data.map((version) => { - // Ignore if version is older than 2018.x + filtered.map((version) => { if (Number.parseInt(version.editorVersionInfo.major, 10) <= 2017) return version; if (!versions[version.editorVersionInfo.major]) @@ -78,8 +96,16 @@ const UnityVersions = ({ selectedRepoVersion }: Props) => { }); } + const hasFilters = searchQuery || statusFilter !== 'all'; + return (
+ {hasFilters && !isLoading && ( +

+ Showing {filtered.length} of {data.length} versions +

+ )} + {failures.length > 0 && ( <>

Current failures

@@ -95,7 +121,7 @@ const UnityVersions = ({ selectedRepoVersion }: Props) => { : Object.keys(versions) .reverse() .map((major) => ( - + ))}
From 0a5fd76d966603ad09b12b74402fc8ec7d27677f Mon Sep 17 00:00:00 2001 From: frostebite Date: Sun, 29 Mar 2026 02:40:56 +0100 Subject: [PATCH 2/3] fix: resolve lint errors in build-row and builds components - Destructure build props to satisfy unicorn/consistent-destructuring - Add aria-label to fallback td for jsx-a11y/control-has-associated-label - Add eslint-disable for intentional sequential batching (no-await-in-loop) - Remove interactive role from non-interactive td element Co-Authored-By: Claude Opus 4.6 --- .../docs/versions/builds/build-row.tsx | 53 ++++++++++--------- .../docs/versions/builds/builds.tsx | 1 + 2 files changed, 30 insertions(+), 24 deletions(-) diff --git a/src/components/docs/versions/builds/build-row.tsx b/src/components/docs/versions/builds/build-row.tsx index e9b50120..771d9e24 100644 --- a/src/components/docs/versions/builds/build-row.tsx +++ b/src/components/docs/versions/builds/build-row.tsx @@ -39,27 +39,28 @@ export default function BuildRow({ children, build, selected, onToggleSelect }: const [expanded, setExpanded] = useState(false); const [toolbarContent, setToolbarContent] = useState('Click to copy'); - const MapBuildStatusToElement = (status: string) => { - const icon = mapBuildStatusToIcon[status]; + const { buildId, status, failure, meta: rawMeta, addedDate, imageType, buildInfo } = build; - switch (status) { + const MapBuildStatusToElement = (buildStatus: string) => { + const icon = mapBuildStatusToIcon[buildStatus]; + + switch (buildStatus) { case 'started': return ; case 'failed': { - const count = build.meta?.failureCount || 0; + const count = rawMeta?.failureCount || 0; const label = count >= 15 ? `${icon} (${count}/15)` : icon; - return {label}; + return {label}; } case 'published': return icon; default: - return status; + return buildStatus; } }; // Build timeline data - const meta = (build.meta || {}) as { [key: string]: any }; - const { addedDate } = build; + const meta = (rawMeta || {}) as { [key: string]: any }; const { lastBuildStart, lastBuildFailure, publishedDate, failureCount = 0 } = meta; const timelineItems: { label: string; time: string; color: string }[] = []; @@ -87,14 +88,17 @@ export default function BuildRow({ children, build, selected, onToggleSelect }: return ( <> - } requiredClaims={{ admin: true }}> + } + requiredClaims={{ admin: true }} + > - {build.status === 'failed' && ( + {status === 'failed' && ( )} @@ -103,16 +107,17 @@ export default function BuildRow({ children, build, selected, onToggleSelect }: setExpanded(!expanded)} className="text-center select-none cursor-pointer text-2xl" + aria-label={expanded ? 'Collapse build details' : 'Expand build details'} > {expanded ? '-' : '+'} - {MapBuildStatusToElement(build.status)} + {MapBuildStatusToElement(status)} - {build.imageType} - {build.buildInfo.baseOs} - {build.buildInfo.targetPlatform} + {imageType} + {buildInfo.baseOs} + {buildInfo.targetPlatform} {/* Inline failure reason row (visible without expanding) */} - {build.status === 'failed' && build.failure?.reason && !expanded && ( + {status === 'failed' && failure?.reason && !expanded && ( - {build.failure.reason.slice(0, 200)} - {build.failure.reason.length > 200 ? '...' : ''} + {failure.reason.slice(0, 200)} + {failure.reason.length > 200 ? '...' : ''} )} @@ -179,7 +184,7 @@ export default function BuildRow({ children, build, selected, onToggleSelect }: )} {/* Failure log viewer */} - {build.status === 'failed' && build.failure && ( + {status === 'failed' && failure && (
- Failure reason: {build.failure.reason || 'Unknown'} - {build.failure.log && ( + Failure reason: {failure.reason || 'Unknown'} + {failure.log && (
Build log
-                      {build.failure.log}
+                      {failure.log}
                     
)} diff --git a/src/components/docs/versions/builds/builds.tsx b/src/components/docs/versions/builds/builds.tsx index b7140dc8..903ba7e3 100644 --- a/src/components/docs/versions/builds/builds.tsx +++ b/src/components/docs/versions/builds/builds.tsx @@ -54,6 +54,7 @@ const BulkActions = ({ selectedIds, onClear }: { selectedIds: string[]; onClear: const batchSize = 5; for (let offset = 0; offset < selectedIds.length; offset += batchSize) { const batch = selectedIds.slice(offset, offset + batchSize); + // eslint-disable-next-line no-await-in-loop -- sequential batching is intentional const results = await Promise.allSettled( batch.map((buildId) => callEndpoint(endpoint, { buildId })), ); From 8d1c2e656cf9863a34664de3a2ec914f31ba1b27 Mon Sep 17 00:00:00 2001 From: frostebite Date: Wed, 1 Apr 2026 17:37:04 +0100 Subject: [PATCH 3/3] feat: add queue diagnostics to admin versions page --- .../versions/builds/build-failure-details.tsx | 19 + .../docs/versions/builds/build-row.tsx | 60 ++- .../docs/versions/builds/builds.tsx | 2 + .../docs/versions/image-versions.tsx | 6 + .../docs/versions/queue-management-panel.tsx | 382 ++++++++++++++++++ 5 files changed, 466 insertions(+), 3 deletions(-) create mode 100644 src/components/docs/versions/queue-management-panel.tsx diff --git a/src/components/docs/versions/builds/build-failure-details.tsx b/src/components/docs/versions/builds/build-failure-details.tsx index 860e7a78..dae655ba 100644 --- a/src/components/docs/versions/builds/build-failure-details.tsx +++ b/src/components/docs/versions/builds/build-failure-details.tsx @@ -18,6 +18,9 @@ const BuildFailureDetails = ({ }: Props) => { const { editorVersion, baseOs, targetPlatform } = ciBuild.buildInfo; const { major, minor, patch } = repoVersionInfo; + const buildRepoVersion = ciBuild.buildInfo.repoVersion; + const jobRepoVersion = repoVersionInfo.version; + const hasRepoVersionDrift = jobRepoVersion !== buildRepoVersion; const reducer = (state, action) => { const { tag, value } = action; @@ -72,6 +75,22 @@ docker build . \\ return (
+

Operational diagnostics

+ + {JSON.stringify( + { + jobRepoVersion, + buildRepoVersion, + hasRepoVersionDrift, + recommendedAction: hasRepoVersionDrift + ? 'Do not keep retrying this build as-is. Inspect stale older-version jobs and supersede them before retrying current jobs.' + : 'Retry/reset/cleanup actions on this page are safe to use if the failure is still active.', + }, + null, + 2, + )} + +

CI Job identification

{JSON.stringify(ciJob, null, 2)}
diff --git a/src/components/docs/versions/builds/build-row.tsx b/src/components/docs/versions/builds/build-row.tsx index 771d9e24..bb91bce9 100644 --- a/src/components/docs/versions/builds/build-row.tsx +++ b/src/components/docs/versions/builds/build-row.tsx @@ -17,6 +17,7 @@ type Props = { children: React.JSX.Element | React.JSX.Element[]; build: Record; selected: boolean; + jobRepoVersion: string; onToggleSelect: () => void; }; @@ -35,11 +36,20 @@ const formatTimestamp = (ts: any): string => { }); }; -export default function BuildRow({ children, build, selected, onToggleSelect }: Props) { +export default function BuildRow({ + children, + build, + selected, + jobRepoVersion, + onToggleSelect, +}: Props) { const [expanded, setExpanded] = useState(false); const [toolbarContent, setToolbarContent] = useState('Click to copy'); const { buildId, status, failure, meta: rawMeta, addedDate, imageType, buildInfo } = build; + const buildRepoVersion = buildInfo.repoVersion; + const hasRepoVersionDrift = + !!jobRepoVersion && !!buildRepoVersion && jobRepoVersion !== buildRepoVersion; const MapBuildStatusToElement = (buildStatus: string) => { const icon = mapBuildStatusToIcon[buildStatus]; @@ -129,8 +139,35 @@ export default function BuildRow({ children, build, selected, onToggleSelect }: + {hasRepoVersionDrift && ( + + Repo drift + + )} + + {hasRepoVersionDrift ? ( + + + {jobRepoVersion} / {buildRepoVersion} + + + ) : ( + buildRepoVersion + )} + {imageType} {buildInfo.baseOs} {buildInfo.targetPlatform} @@ -140,7 +177,7 @@ export default function BuildRow({ children, build, selected, onToggleSelect }: - + {failure.reason.slice(0, 200)} {failure.reason.length > 200 ? '...' : ''} @@ -148,7 +185,7 @@ export default function BuildRow({ children, build, selected, onToggleSelect }: )} {expanded && ( - + {/* Build timeline */} {timelineItems.length > 0 && (
)} + {hasRepoVersionDrift && ( +
+ Repo-version drift detected: job repo version is `{jobRepoVersion}` + while this build carries repo version `{buildRepoVersion}`. That usually means an + older failed job is being retried against the latest repo version instead of being + superseded. +
+ )} {/* Failure log viewer */} {status === 'failed' && failure && (
{ Status Build ID + Repo Version Image type OS Target Platform @@ -212,6 +213,7 @@ const Builds = ({ ciJobId, repoVersionInfo, editorVersionInfo }: Props) => { key={build.buildId} build={build} selected={selectedBuilds.has(build.buildId)} + jobRepoVersion={repoVersionInfo.version} onToggleSelect={() => toggleBuild(build.buildId)} > {expandable.expandedRowRender(build)} diff --git a/src/components/docs/versions/image-versions.tsx b/src/components/docs/versions/image-versions.tsx index b14a3cba..82fccb6a 100644 --- a/src/components/docs/versions/image-versions.tsx +++ b/src/components/docs/versions/image-versions.tsx @@ -4,6 +4,8 @@ import CleanUpStuckBuildsButton from './clean-up-stuck-builds-button'; import ResetAllFailedBuildsButton from './reset-all-failed-builds-button'; import BuildStatusDashboard from './build-status-dashboard'; import UnityVersions from './unity-versions'; +import QueueManagementPanel from './queue-management-panel'; +import { SimpleAuthCheck } from '@site/src/components/auth/safe-auth-check'; import styles from './unity-version.module.scss'; interface Props { @@ -65,6 +67,10 @@ const ImageVersions = ({ versions }: Props) => { + } requiredClaims={{ admin: true }}> + + +
({ + display: 'inline-flex', + alignItems: 'center', + gap: 6, + padding: '4px 12px', + borderRadius: 6, + border: `1px solid ${color}33`, + background: `${color}11`, + fontSize: '0.85em', + fontWeight: 500, +}); + +const sectionStyle: React.CSSProperties = { + padding: '12px', + borderRadius: 8, + border: '1px solid #33333322', + background: '#fafafa08', +}; + +const tableStyle: React.CSSProperties = { + width: '100%', + borderCollapse: 'collapse', + fontSize: '0.85em', +}; + +const cellStyle: React.CSSProperties = { + textAlign: 'left', + padding: '6px 8px', + borderBottom: '1px solid #33333322', + verticalAlign: 'top', +}; + +const getJobRepoVersion = (job: QueueJob | undefined): string => + job?.repoVersionInfo?.version || ''; + +const QueueManagementPanel = ({ selectedRepoVersion }: Props) => { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + const fetchQueueStatus = async () => { + setLoading(true); + setError(''); + try { + const response = await fetch(`${config.backendUrl}/queueStatus`); + const body = await response.json(); + if (!response.ok) { + throw new Error(body.message || `Request failed (${response.status})`); + } + setData(body); + } catch (fetchError: any) { + setError(fetchError.message || 'Failed to load queue status'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void fetchQueueStatus(); + }, []); + + const diagnostics = useMemo(() => { + const jobs = data?.jobs || []; + const builds = data?.builds || []; + const jobMap = new Map(); + + jobs.forEach((job: any) => { + const id = job.NO_ID_FIELD || job.id; + if (id) { + jobMap.set(id, job); + } + }); + + const editorJobs = jobs.filter((job) => job.imageType === 'editor'); + const staleFailedJobs = editorJobs + .filter( + (job: any) => + job.status === 'failed' && + getJobRepoVersion(job) && + getJobRepoVersion(job) !== selectedRepoVersion, + ) + .map((job: any) => ({ + jobId: job.NO_ID_FIELD || job.id, + jobRepoVersion: getJobRepoVersion(job), + status: job.status, + })); + + const staleCreatedJobs = editorJobs + .filter( + (job: any) => + job.status === 'created' && + getJobRepoVersion(job) && + getJobRepoVersion(job) !== selectedRepoVersion, + ) + .map((job: any) => ({ + jobId: job.NO_ID_FIELD || job.id, + jobRepoVersion: getJobRepoVersion(job), + status: job.status, + })); + + const repoDriftBuilds = builds + .filter((build) => build.imageType === 'editor') + .map((build) => { + const job = jobMap.get(build.relatedJobId); + return { + ...build, + jobRepoVersion: getJobRepoVersion(job), + }; + }) + .filter( + (build) => + build.jobRepoVersion && + build.buildInfo.repoVersion && + build.jobRepoVersion !== build.buildInfo.repoVersion, + ); + + const failedWithDockerInfo = builds.filter( + (build) => build.status === 'failed' && build.dockerInfo?.digest, + ); + + return { + staleFailedJobs, + staleCreatedJobs, + repoDriftBuilds, + failedWithDockerInfo, + totals: { + jobs: jobs.length, + builds: builds.length, + failedJobs: editorJobs.filter((job) => job.status === 'failed').length, + createdJobs: editorJobs.filter((job) => job.status === 'created').length, + }, + }; + }, [data, selectedRepoVersion]); + + return ( +
+
+ Admin Queue Management + + Global queue diagnostics for the selected repo version `{selectedRepoVersion}` + + +
+ + {loading && ( +
+ Loading queue diagnostics... +
+ )} + + {!loading && error && ( +
+ Queue diagnostics failed: {error} +
+ )} + + {!loading && !error && ( + <> +
+ + Jobs: {diagnostics.totals.jobs} + + + Builds: {diagnostics.totals.builds} + + + Failed editor jobs: {diagnostics.totals.failedJobs} + + + Created editor jobs: {diagnostics.totals.createdJobs} + + + Older-version failed jobs: {diagnostics.staleFailedJobs.length} + + + Repo-version drift builds: {diagnostics.repoDriftBuilds.length} + +
+ +

+ Use this panel to identify queue states that need intervention. Existing admin actions + on this page remain the operational controls: reset failed builds, retry builds, and + clean up stuck builds. +

+ +
+
+

Repo-Version Drift Builds

+ + + + + + + + + + + + {diagnostics.repoDriftBuilds.slice(0, 12).map((build) => ( + + + + + + + + ))} + {diagnostics.repoDriftBuilds.length === 0 && ( + + + + )} + +
Build IDJob IDJob RepoBuild RepoAction
{build.buildId}{build.relatedJobId}{build.jobRepoVersion}{build.buildInfo.repoVersion} + Investigate retry churn. This build is attached to an older job but is + retrying against a newer repo version. +
+ No repo-version drift detected. +
+
+ +
+

Older-Version Failed Jobs

+ + + + + + + + + + {diagnostics.staleFailedJobs.slice(0, 12).map((job) => ( + + + + + + ))} + {diagnostics.staleFailedJobs.length === 0 && ( + + + + )} + +
Job IDJob RepoAction
{job.jobId}{job.jobRepoVersion} + Should be superseded so it stops competing with `{selectedRepoVersion}`. +
+ No older-version failed jobs detected. +
+
+ +
+

Older-Version Created Jobs

+ + + + + + + + + + {diagnostics.staleCreatedJobs.slice(0, 12).map((job) => ( + + + + + + ))} + {diagnostics.staleCreatedJobs.length === 0 && ( + + + + )} + +
Job IDJob RepoAction
{job.jobId}{job.jobRepoVersion} + Candidate for superseding if it remains after the queue moves to the latest + repo version. +
+ No older-version created jobs detected. +
+
+ +
+

Failed Builds With Docker Metadata

+ + + + + + + + + + {diagnostics.failedWithDockerInfo.slice(0, 12).map((build) => ( + + + + + + ))} + {diagnostics.failedWithDockerInfo.length === 0 && ( + + + + )} + +
Build IDDigestAction
{build.buildId}{build.dockerInfo?.digest} + Reconcile this failed build against publication state before retrying. +
+ No failed builds with Docker metadata detected. +
+
+
+ + )} +
+ ); +}; + +export default QueueManagementPanel;