Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions src/components/docs/versions/build-status-dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,31 @@
selectedRepoVersion: string | undefined;
}

const minutesSinceTimestamp = (
timestamp?: {
seconds?: number;
} | null,
): number | null => {
if (!timestamp?.seconds) return null;
const diffMs = Date.now() - timestamp.seconds * 1000;
return diffMs < 0 ? 0 : Math.floor(diffMs / 60000);
};

const formatAgeMinutes = (minutes: number | null): string => {
if (minutes === null) return 'n/a';
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return remainingMinutes === 0 ? `${hours}h` : `${hours}h ${remainingMinutes}m`;
};

const BuildStatusDashboard = ({ selectedRepoVersion }: Props) => {
const firestore = useFirestore();
const ciBuilds = firestore
.collection('ciBuilds')
.where('buildInfo.repoVersion', '==', selectedRepoVersion || '__none__');

const { status, data } = useFirestoreCollectionData<{ [key: string]: any }>(ciBuilds);

Check warning on line 33 in src/components/docs/versions/build-status-dashboard.tsx

View workflow job for this annotation

GitHub Actions / Code styles

typescript(no-explicit-any)

Unexpected `any`. Specify a different type.

if (!selectedRepoVersion) return null;

Expand All @@ -31,6 +49,12 @@
const maxedOut = builds.filter(
(b) => b.status === 'failed' && (b.meta?.failureCount || 0) >= 15,
).length;
const startedAges = builds
.filter((b) => b.status === 'started')
.map((b) => minutesSinceTimestamp(b.meta?.lastBuildStart))
.filter((minutes): minutes is number => minutes !== null);
const staleStarted = startedAges.filter((minutes) => minutes >= 45).length;
const oldestStarted = startedAges.length > 0 ? Math.max(...startedAges) : null;
const total = builds.length;

const statStyle = (color: string): React.CSSProperties => ({
Expand Down Expand Up @@ -74,6 +98,14 @@
Stuck (15+): <strong>{maxedOut}</strong>
</span>
)}
{staleStarted > 0 && (
<span style={statStyle('#92400e')}>
Started 45m+: <strong>{staleStarted}</strong>
</span>
)}
<span style={statStyle('#475569')}>
Oldest started: <strong>{formatAgeMinutes(oldestStarted)}</strong>
</span>
<span
style={{
marginLeft: 'auto',
Expand Down
153 changes: 153 additions & 0 deletions src/components/docs/versions/image-job-admin-actions.tsx
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;
Comment on lines +41 to +44
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid subscribing to Firestore for every non-failed job row.

useFirestoreCollectionData is invoked unconditionally, so a per-row ciBuilds listener (filtered by relatedJobId) is created for every UnityVersion rendered — including jobs that are completed, 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 the status !== '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
-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 ImageJobAdminActions = ({ ciJobId, status }: Props) => {
+  if (status !== 'failed') return null;
+  return <ImageJobAdminActionsInner ciJobId={ciJobId} />;
+};
+
+const ImageJobAdminActionsInner = ({ ciJobId }: { ciJobId: string }) => {
+  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 (buildStatus === 'loading') return null;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/docs/versions/image-job-admin-actions.tsx` around lines 41 -
44, The problem is that useFirestoreCollectionData is called unconditionally,
creating a Firestore listener per row even when status !== 'failed'; fix by
moving the ciBuilds construction and the useFirestoreCollectionData<BuildRecord>
hook into a new child component (e.g., FailedJobBuilds or ImageJobAdminBuilds)
that accepts ciJobId as a prop and renders the build UI, then in the parent
component do an early return when status !== 'failed' so the child (and thus
ciBuilds/useFirestoreCollectionData) is only mounted for failed jobs; reference
ciBuilds, useFirestoreCollectionData, BuildRecord, ciJobId, status and
buildStatus when updating the code.


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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Parse JSON defensively so non-JSON error bodies don't mask the real failure.

response.json() runs before the response.ok check, so any non-JSON error response (gateway HTML 5xx, empty body 504, auth proxy redirect, etc.) throws SyntaxError: Unexpected token ... and the real status code/message never reaches the admin via the notification. Read the body as text first and parse it safely.

🛡️ 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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})`);
}
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;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/docs/versions/image-job-admin-actions.tsx` around lines 67 -
71, The code currently calls response.json() unconditionally which throws on
non-JSON error bodies; change the logic in the block surrounding the response
handling (the const body = await response.json(); and the subsequent if
(!response.ok) branch) to first read the raw text via response.text(), then try
to JSON.parse that text inside a try/catch to produce a parsed body; when
response.ok is false build the detail using parsedBody.message /
parsedBody.error if available, otherwise fall back to the raw text and finally
to `Request failed (${response.status})`, ensuring any JSON parse errors are
swallowed and the real status and text are surfaced in the thrown Error.

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;
67 changes: 50 additions & 17 deletions src/components/docs/versions/image-versions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@
import styles from './unity-version.module.scss';

interface Props {
versions: { [key: string]: any }[];

Check warning on line 12 in src/components/docs/versions/image-versions.tsx

View workflow job for this annotation

GitHub Actions / Code styles

typescript(no-explicit-any)

Unexpected `any`. Specify a different type.
}

export type StatusFilter = 'all' | 'started' | 'failed' | 'published' | 'stuck';

const ImageVersions = ({ versions }: Props) => {
const [selectedVersion, setSelectedVersion] = useState<any>(versions[0].NO_ID_FIELD);

Check warning on line 18 in src/components/docs/versions/image-versions.tsx

View workflow job for this annotation

GitHub Actions / Code styles

typescript(no-explicit-any)

Unexpected `any`. Specify a different type.
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');

Expand All @@ -40,29 +40,62 @@
minWidth: 220,
};

const headerCardStyle: React.CSSProperties = {
display: 'grid',
gap: 12,
padding: '14px 16px',
borderRadius: 10,
border: '1px solid #33333322',
background: '#fafafa08',
marginBottom: 12,
};

const toolbarStyle: React.CSSProperties = {
display: 'flex',
flexWrap: 'wrap',
gap: 10,
alignItems: 'center',
justifyContent: 'space-between',
};

return (
<main className={styles.versionsPanel}>
<h1>Supported Editor Versions</h1>

<div>
<span>Docker repo version: </span>
<div style={headerCardStyle}>
<div style={toolbarStyle}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<strong>Docker repo version</strong>
<select
value={selectedVersion}
onChange={(event) => setSelectedVersion(event.target.value)}
style={selectStyle}
>
{versions.map((version) => {
const { NO_ID_FIELD: id } = version;

<select onChange={(event) => setSelectedVersion(event.target.value)}>
{versions.map((version) => {
const { NO_ID_FIELD: id } = version;
return (
<option key={id} value={id}>
{id}
</option>
);
})}
</select>
</div>

return (
<option key={id} value={id}>
{id}
</option>
);
})}
</select>
<span style={{ float: 'right', display: 'flex', alignItems: 'center', gap: 8 }}>
<ResetAllFailedBuildsButton />
<CleanUpStuckBuildsButton />
<SignInSignOutButton />
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<SimpleAuthCheck fallback={<span />} requiredClaims={{ admin: true }}>
<ResetAllFailedBuildsButton />
<CleanUpStuckBuildsButton />
</SimpleAuthCheck>
<SignInSignOutButton />
</div>
</div>

<p style={{ margin: 0, opacity: 0.7, fontSize: '0.85em' }}>
Admin controls for queue recovery, retry management, and selected-repo diagnostics live
below. Use the dashboard first, then the queue panel for root-cause detail.
</p>
</div>

<BuildStatusDashboard selectedRepoVersion={selectedVersion} />
Expand Down
Loading
Loading