From 0b9b8a4f16bfd26706fff43a270c3a1ef5a9dd65 Mon Sep 17 00:00:00 2001 From: Cameron Dawson Date: Tue, 19 May 2026 07:47:15 -0700 Subject: [PATCH] Handle expired Taskcluster tasks in the details panel Previously, selecting a job whose Taskcluster task had expired caused queue.task() to reject inside fetchTaskData, which rejected the whole Promise.all in useJobDetails and left selectedJobFull as null. Both SummaryPanel and TabsPanel gate their content on selectedJobFull, so the details panel rendered completely blank with no explanation. Catch the queue.task() failure, fall back to defaults so the rest of the panel renders, and surface a "Taskcluster task expired" badge next to the State line. Disable actions that need a live task definition (Retrigger, Cancel, Backfill, Create Interactive Task, Create Gecko Profile, Generate side-by-side, Confirm Test Failures, Custom Action) with tooltips explaining why. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../details/summary/ActionBar_test.jsx | 51 ++ .../job-view/details/summary/LogItem.test.jsx | 29 + .../details/summary/StatusPanel_test.jsx | 27 + .../details/summary/SummaryPanel_test.jsx | 65 ++ .../ui/job-view/details/useJobDetails.test.js | 54 ++ tests/ui/logviewer/ClassicLogViewer_test.jsx | 43 + tests/ui/logviewer/useLogViewer_test.js | 12 + ui/job-view/details/DetailsPanel.jsx | 2 + ui/job-view/details/summary/ActionBar.jsx | 747 +++++++++--------- ui/job-view/details/summary/LogItem.jsx | 18 + ui/job-view/details/summary/LogUrls.jsx | 11 +- ui/job-view/details/summary/StatusPanel.jsx | 16 +- ui/job-view/details/summary/SummaryPanel.jsx | 176 +++-- ui/job-view/details/useJobDetails.js | 22 +- ui/logviewer/ClassicLogViewer.jsx | 8 + ui/logviewer/useLogViewer.js | 10 +- 16 files changed, 811 insertions(+), 480 deletions(-) create mode 100644 tests/ui/job-view/details/summary/ActionBar_test.jsx create mode 100644 tests/ui/job-view/details/summary/StatusPanel_test.jsx create mode 100644 tests/ui/job-view/details/summary/SummaryPanel_test.jsx create mode 100644 tests/ui/job-view/details/useJobDetails.test.js create mode 100644 tests/ui/logviewer/ClassicLogViewer_test.jsx diff --git a/tests/ui/job-view/details/summary/ActionBar_test.jsx b/tests/ui/job-view/details/summary/ActionBar_test.jsx new file mode 100644 index 00000000000..ac8958e4c0d --- /dev/null +++ b/tests/ui/job-view/details/summary/ActionBar_test.jsx @@ -0,0 +1,51 @@ +import { render, screen } from '@testing-library/react'; + +import { ActionBar } from '../../../../../ui/job-view/details/summary/ActionBar'; +import { + usePushesStore, + initialState as pushesInitialState, +} from '../../../../../ui/job-view/stores/pushesStore'; + +const baseProps = { + selectedJobFull: { + id: 1, + task_id: 'TASK_ID', + state: 'completed', + push_id: 1, + resultStatus: 'success', + job_group_name: 'Build', + job_type_name: 'build-linux', + job_type_symbol: 'B', + submit_timestamp: 0, + }, + user: { isLoggedIn: true, email: 'me@example.com' }, + logParseStatus: 'parsed', + currentRepo: { name: 'autoland', tc_root_url: 'https://tc.example' }, + jobLogUrls: [], + jobDetails: [], +}; + +describe('ActionBar expired-task disabling', () => { + beforeEach(() => { + usePushesStore.setState({ + ...pushesInitialState, + decisionTaskMap: { 1: { id: 'DEC_TASK' } }, + }); + }); + + it('does not disable Retrigger by default', () => { + render(); + + expect(screen.getByTitle(/^Retrigger job/)).not.toBeDisabled(); + }); + + it('disables Retrigger when taskExpired is true', () => { + render(); + + const retrigger = screen.getByTitle(/^Retrigger job/); + expect(retrigger).toBeDisabled(); + expect(retrigger.getAttribute('title')).toContain( + 'Taskcluster task expired', + ); + }); +}); diff --git a/tests/ui/job-view/details/summary/LogItem.test.jsx b/tests/ui/job-view/details/summary/LogItem.test.jsx index d0fc58b8779..c3aa1d76c95 100644 --- a/tests/ui/job-view/details/summary/LogItem.test.jsx +++ b/tests/ui/job-view/details/summary/LogItem.test.jsx @@ -216,6 +216,35 @@ describe('LogItem', () => { }); }); + describe('expired Taskcluster task', () => { + it('renders a disabled button with an expired tooltip even when the log was parsed', () => { + const logUrls = [createLogUrl({ parse_status: 'parsed' })]; + + render( + + View Log + , + ); + + // No active link should be rendered + expect(screen.queryByTestId('logviewer-btn')).not.toBeInTheDocument(); + + const button = screen.getByRole('button'); + expect(button).toHaveClass('disabled'); + expect(button).toHaveAttribute( + 'title', + 'Taskcluster task expired — log no longer available', + ); + }); + }); + describe('list item wrapper', () => { it('renders inside an li element', () => { const logUrls = [createLogUrl()]; diff --git a/tests/ui/job-view/details/summary/StatusPanel_test.jsx b/tests/ui/job-view/details/summary/StatusPanel_test.jsx new file mode 100644 index 00000000000..f11d69733aa --- /dev/null +++ b/tests/ui/job-view/details/summary/StatusPanel_test.jsx @@ -0,0 +1,27 @@ +import { render, screen } from '@testing-library/react'; + +import StatusPanel from '../../../../../ui/job-view/details/summary/StatusPanel'; + +const baseJob = { + resultStatus: 'success', + result: 'success', + state: 'completed', +}; + +describe('StatusPanel', () => { + it('does not show the Taskcluster expired badge by default', () => { + render(); + + expect( + screen.queryByTestId('taskcluster-expired-badge'), + ).not.toBeInTheDocument(); + }); + + it('shows the Taskcluster expired badge when taskExpired is true', () => { + render(); + + const badge = screen.getByTestId('taskcluster-expired-badge'); + expect(badge).toBeInTheDocument(); + expect(badge).toHaveTextContent('Expired'); + }); +}); diff --git a/tests/ui/job-view/details/summary/SummaryPanel_test.jsx b/tests/ui/job-view/details/summary/SummaryPanel_test.jsx new file mode 100644 index 00000000000..87822cdb551 --- /dev/null +++ b/tests/ui/job-view/details/summary/SummaryPanel_test.jsx @@ -0,0 +1,65 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; + +import SummaryPanel from '../../../../../ui/job-view/details/summary/SummaryPanel'; +import { + usePushesStore, + initialState as pushesInitialState, +} from '../../../../../ui/job-view/stores/pushesStore'; + +const selectedJobFull = { + id: 1, + task_id: 'TASK_ID', + state: 'completed', + result: 'success', + resultStatus: 'success', + push_id: 1, + searchStr: 'test job', + submit_timestamp: 0, + job_type_name: 'build-linux', + job_group_name: 'Build', + job_type_symbol: 'B', + build_platform: 'linux', +}; + +const renderPanel = (extraProps = {}) => + render( + + + , + ); + +describe('SummaryPanel log parsing status', () => { + beforeEach(() => { + usePushesStore.setState({ + ...pushesInitialState, + decisionTaskMap: { 1: { id: 'DEC_TASK' } }, + }); + }); + + it('shows the parse status when the task is not expired', () => { + renderPanel(); + + expect(screen.getByText('Log parsing status:')).toBeInTheDocument(); + expect(screen.getByText('parsed')).toBeInTheDocument(); + expect( + screen.queryByText('Expired, not available'), + ).not.toBeInTheDocument(); + }); + + it('shows an expired message for the log status when the task is expired', () => { + renderPanel({ taskExpired: true }); + + expect(screen.getByText('Expired, not available')).toBeInTheDocument(); + expect(screen.queryByText('parsed')).not.toBeInTheDocument(); + }); +}); diff --git a/tests/ui/job-view/details/useJobDetails.test.js b/tests/ui/job-view/details/useJobDetails.test.js new file mode 100644 index 00000000000..262a16d1f13 --- /dev/null +++ b/tests/ui/job-view/details/useJobDetails.test.js @@ -0,0 +1,54 @@ +import { Queue } from 'taskcluster-client-web'; + +import { fetchTaskData } from '../../../../ui/job-view/details/useJobDetails'; + +describe('fetchTaskData', () => { + let consoleErrorSpy; + + beforeEach(() => { + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it('returns defaults without args and does not mark expired', async () => { + expect(await fetchTaskData(null, null)).toEqual({ + testGroups: [], + taskQueueId: null, + taskExpired: false, + }); + }); + + it('marks taskExpired when the Taskcluster task lookup fails', async () => { + Queue.mockImplementationOnce(() => ({ + task: jest.fn().mockRejectedValue(new Error('404: task not found')), + })); + + const result = await fetchTaskData('EXPIRED_TASK_ID', 'https://tc.example'); + + expect(result).toEqual({ + testGroups: [], + taskQueueId: null, + taskExpired: true, + }); + }); + + it('returns task data with taskExpired false on success', async () => { + Queue.mockImplementationOnce(() => ({ + task: jest.fn().mockResolvedValue({ + taskQueueId: 'gecko-3/b-linux', + payload: { env: {} }, + }), + })); + + const result = await fetchTaskData('LIVE_TASK', 'https://tc.example'); + + expect(result).toEqual({ + testGroups: [], + taskQueueId: 'gecko-3/b-linux', + taskExpired: false, + }); + }); +}); diff --git a/tests/ui/logviewer/ClassicLogViewer_test.jsx b/tests/ui/logviewer/ClassicLogViewer_test.jsx new file mode 100644 index 00000000000..b4529df869b --- /dev/null +++ b/tests/ui/logviewer/ClassicLogViewer_test.jsx @@ -0,0 +1,43 @@ +import { render, screen } from '@testing-library/react'; + +import ClassicLogViewer from '../../../ui/logviewer/ClassicLogViewer'; + +describe('ClassicLogViewer error states', () => { + beforeEach(() => { + global.fetch = jest.fn(); + }); + + afterEach(() => { + delete global.fetch; + }); + + it('shows an expired message when the log fetch returns 404', async () => { + global.fetch.mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + }); + + render(); + + expect( + await screen.findByText( + 'This log has expired and is no longer available.', + ), + ).toBeInTheDocument(); + }); + + it('shows the generic error message for non-404 failures', async () => { + global.fetch.mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Server Error', + }); + + render(); + + expect( + await screen.findByText(/Error loading log:/), + ).toBeInTheDocument(); + }); +}); diff --git a/tests/ui/logviewer/useLogViewer_test.js b/tests/ui/logviewer/useLogViewer_test.js index 4da5aa9f9d3..75cc66ca707 100644 --- a/tests/ui/logviewer/useLogViewer_test.js +++ b/tests/ui/logviewer/useLogViewer_test.js @@ -58,10 +58,22 @@ describe('useLogViewer', () => { await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(result.current.error).toBe('Failed to fetch log: 404 Not Found'); + expect(result.current.errorStatus).toBe(404); expect(result.current.lines).toEqual([]); expect(result.current.lineCount).toBe(0); }); + test('errorStatus is null for non-HTTP failures', async () => { + global.fetch.mockRejectedValue(new Error('Network down')); + + const { result } = renderHook(() => useLogViewer({ url: 'http://bad.txt' })); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.error).toBe('Network down'); + expect(result.current.errorStatus).toBeNull(); + }); + test('returns empty state when no URL', () => { const { result } = renderHook(() => useLogViewer({})); diff --git a/ui/job-view/details/DetailsPanel.jsx b/ui/job-view/details/DetailsPanel.jsx index d569467421d..480a306e60f 100644 --- a/ui/job-view/details/DetailsPanel.jsx +++ b/ui/job-view/details/DetailsPanel.jsx @@ -44,6 +44,7 @@ function DetailsPanel({ classifications, testGroups, bugs, + taskExpired, } = useJobDetails(selectedJob, currentRepo, pushList, frameworks); const togglePinBoardVisibility = useCallback(() => { @@ -92,6 +93,7 @@ function DetailsPanel({ logViewerFullUrl={logViewerFullUrl} bugs={bugs} user={user} + taskExpired={taskExpired} /> state.decisionTaskMap); - this.state = { - customJobActionsShowing: false, - }; - } + const getResourceUsageProfile = () => + jobDetails.find((artifact) => isResourceUsageProfile(artifact.value)); - componentDidMount() { - window.addEventListener(thEvents.openLogviewer, this.onOpenLogviewer); - window.addEventListener(thEvents.openRawLog, this.onOpenRawLog); - window.addEventListener(thEvents.openGeckoProfile, this.onOpenGeckoProfile); - window.addEventListener(thEvents.jobRetrigger, this.onRetriggerJob); - } + const canCancel = () => + selectedJobFull.state === 'pending' || selectedJobFull.state === 'running'; - componentWillUnmount() { - window.removeEventListener(thEvents.openLogviewer, this.onOpenLogviewer); - window.removeEventListener(thEvents.openRawLog, this.onOpenRawLog); - window.removeEventListener( - thEvents.openGeckoProfile, - this.onOpenGeckoProfile, - ); - window.removeEventListener(thEvents.jobRetrigger, this.onRetriggerJob); - } + const canBackfill = () => !isTryRepo && !taskExpired; - onRetriggerJob = (event) => { - this.retriggerJob([event.detail.job]); - }; + const backfillButtonTitle = () => { + let title = ''; - // Open the logviewer and provide notifications if it isn't available - onOpenLogviewer = () => { - const { logParseStatus } = this.props; - - switch (logParseStatus) { - case 'pending': - notify('Log parsing in progress, log viewer not yet available'); - break; - case 'failed': - notify('Log parsing has failed, log viewer is unavailable', 'warning'); - break; - case 'skipped-size': - notify('Log parsing was skipped, log viewer is unavailable', 'warning'); - break; - case 'unavailable': - notify('No logs available for this job'); - break; - case 'parsed': - document.querySelector('.logviewer-btn').click(); + if (isTryRepo) { + title = title.concat('backfill not available in this repository'); } - }; - - // Open the raw log and provide notifications if it isn't available - onOpenRawLog = () => { - const rawLogButton = document.querySelector('.rawlog-btn'); - if (rawLogButton) { - rawLogButton.click(); - } else { - notify('No logs available for this job'); + if (taskExpired) { + title = title.concat( + title ? ' / ' : '', + 'Taskcluster task expired — backfill unavailable', + ); } - }; - // Open the gecko profile and provide notifications if it isn't available - onOpenGeckoProfile = () => { - const { selectedJobFull } = this.props; - const resourceUsageProfile = this.getResourceUsageProfile(); - - if (resourceUsageProfile) { - window.open( - getPerfAnalysisUrl(resourceUsageProfile.url, selectedJobFull), - '_blank', - ); + if (title === '') { + title = + 'Trigger jobs of this type on prior pushes ' + + 'to fill in gaps where the job was not run'; } else { - notify('No resource usage profile available for this job'); + // Cut off trailing '/ ' if one exists, capitalize first letter + title = title.replace(/\/ $/, ''); + title = title.replace(/^./, (l) => l.toUpperCase()); } + return title; }; - getResourceUsageProfile = () => { - const { jobDetails } = this.props; - return jobDetails.find((artifact) => - isResourceUsageProfile(artifact.value), - ); - }; + const retriggerJob = async (jobs) => { + // Spin the retrigger button when retriggers happen + document + .querySelector('#retrigger-btn > svg') + .classList.remove('action-bar-spin'); + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + document + .querySelector('#retrigger-btn > svg') + .classList.add('action-bar-spin'); + }); + }); - canCancel = () => { - const { selectedJobFull } = this.props; - return ( - selectedJobFull.state === 'pending' || selectedJobFull.state === 'running' - ); + JobModel.retrigger(jobs, currentRepo, notify, 1, decisionTaskMap); }; - createGeckoProfile = async () => { - const { - selectedJobFull, - notify, - decisionTaskMap, - currentRepo, - } = this.props; - return triggerTask( + const createGeckoProfile = async () => + triggerTask( selectedJobFull, notify, decisionTaskMap, currentRepo, geckoProfileTaskName, ); - }; - createSideBySide = async () => { - const { - selectedJobFull, - notify, - decisionTaskMap, - currentRepo, - } = this.props; + const createSideBySide = async () => { await triggerTask( selectedJobFull, notify, @@ -167,34 +127,13 @@ class ActionBar extends React.PureComponent { ); }; - retriggerJob = async (jobs) => { - const { decisionTaskMap, currentRepo } = this.props; - - // Spin the retrigger button when retriggers happen - document - .querySelector('#retrigger-btn > svg') - .classList.remove('action-bar-spin'); - window.requestAnimationFrame(() => { - window.requestAnimationFrame(() => { - document - .querySelector('#retrigger-btn > svg') - .classList.add('action-bar-spin'); - }); - }); - - JobModel.retrigger(jobs, currentRepo, notify, 1, decisionTaskMap); - }; - - backfillJob = async () => { - const { selectedJobFull, decisionTaskMap, currentRepo } = this.props; - - if (!this.canBackfill()) { + const backfillJob = async () => { + if (!canBackfill()) { return; } if (!selectedJobFull.id) { notify('Job not yet loaded for backfill', 'warning'); - return; } @@ -232,46 +171,11 @@ class ActionBar extends React.PureComponent { ); }; - handleConfirmFailure = async () => { - const { - selectedJobFull, - notify, - decisionTaskMap, - currentRepo, - } = this.props; + const handleConfirmFailure = async () => { confirmFailure(selectedJobFull, notify, decisionTaskMap, currentRepo); }; - // Can we backfill? At the moment, this only ensures we're not in a 'try' repo. - canBackfill = () => { - const { isTryRepo } = this.props; - - return !isTryRepo; - }; - - backfillButtonTitle = () => { - const { isTryRepo } = this.props; - let title = ''; - - if (isTryRepo) { - title = title.concat('backfill not available in this repository'); - } - - if (title === '') { - title = - 'Trigger jobs of this type on prior pushes ' + - 'to fill in gaps where the job was not run'; - } else { - // Cut off trailing '/ ' if one exists, capitalize first letter - title = title.replace(/\/ $/, ''); - title = title.replace(/^./, (l) => l.toUpperCase()); - } - return title; - }; - - createInteractiveTask = async () => { - const { user, selectedJobFull, decisionTaskMap, currentRepo } = this.props; - + const createInteractiveTask = async () => { const { id: decisionTaskId } = decisionTaskMap[selectedJobFull.push_id]; const results = await TaskclusterModel.load( decisionTaskId, @@ -310,9 +214,7 @@ class ActionBar extends React.PureComponent { } }; - cancelJobs = (jobs) => { - const { decisionTaskMap, currentRepo } = this.props; - + const cancelJobs = (jobs) => { JobModel.cancel( jobs.filter(({ state }) => state === 'pending' || state === 'running'), currentRepo, @@ -321,265 +223,337 @@ class ActionBar extends React.PureComponent { ); }; - cancelJob = () => { - this.cancelJobs([this.props.selectedJobFull]); + const cancelJob = () => { + cancelJobs([selectedJobFull]); }; - toggleCustomJobActions = () => { - const { customJobActionsShowing } = this.state; - - this.setState({ customJobActionsShowing: !customJobActionsShowing }); + const toggleCustomJobActions = () => { + setCustomJobActionsShowing((showing) => !showing); }; - render() { - const { - selectedJobFull, - logViewerUrl = null, - logViewerFullUrl = null, - jobLogUrls = [], - currentRepo, - jobDetails, - } = this.props; - const { customJobActionsShowing } = this.state; - const resourceUsageProfile = this.getResourceUsageProfile(); - - // For running tasks, add the live.log from artifacts for raw log only - let rawLogUrls = jobLogUrls; - if ( - selectedJobFull.state === 'running' && - jobDetails && - !jobLogUrls.length - ) { - const liveLog = jobDetails.find((detail) => - detail.value.includes('live.log'), - ); - if (liveLog) { - rawLogUrls = [{ url: liveLog.url, name: 'live.log', id: 'live' }]; - } + // For running tasks, fall back to the live.log artifact for raw log only. + let rawLogUrls = jobLogUrls; + if ( + selectedJobFull.state === 'running' && + jobDetails && + !jobLogUrls.length + ) { + const liveLog = jobDetails.find((detail) => + detail.value.includes('live.log'), + ); + if (liveLog) { + rawLogUrls = [{ url: liveLog.url, name: 'live.log', id: 'live' }]; } + } + const firstRawLogUrl = rawLogUrls.find( + (logUrl) => !logUrl.name.includes('perfherder-data'), + )?.url; + + // Re-register window listeners whenever values they read change so the + // captured closures stay current. + useEffect(() => { + const onOpenLogviewer = () => { + if (!taskExpired && logParseStatus === 'parsed' && logViewerUrl) { + window.open(logViewerUrl, '_blank', 'noopener,noreferrer'); + } + // When unavailable (or the task expired), the LogUrls button is rendered + // disabled with an explanatory tooltip, so we don't need a notification. + }; - return ( -
- + {customJobActionsShowing && ( + + )} +
+ ); } ActionBar.propTypes = { - decisionTaskMap: PropTypes.shape({}).isRequired, user: PropTypes.shape({}).isRequired, selectedJobFull: PropTypes.shape({}).isRequired, logParseStatus: PropTypes.string.isRequired, @@ -589,12 +563,7 @@ ActionBar.propTypes = { isTryRepo: PropTypes.bool, logViewerUrl: PropTypes.string, logViewerFullUrl: PropTypes.string, + taskExpired: PropTypes.bool, }; -// Wrapper to inject Zustand state into class component -function ActionBarWrapper(props) { - const decisionTaskMap = usePushesStore((state) => state.decisionTaskMap); - return ; -} - -export default ActionBarWrapper; +export default React.memo(ActionBar); diff --git a/ui/job-view/details/summary/LogItem.jsx b/ui/job-view/details/summary/LogItem.jsx index f34b0a2fcbb..2b2c8769c77 100644 --- a/ui/job-view/details/summary/LogItem.jsx +++ b/ui/job-view/details/summary/LogItem.jsx @@ -47,8 +47,25 @@ export default function LogItem(props) { logViewerFullUrl = null, logKey, logDescription, + taskExpired = false, } = props; + // When the Taskcluster task has expired, its log artifacts are gone too, so + // render a disabled button explaining why rather than a dead link. + if (taskExpired) { + return ( +
  • + +
  • + ); + } + return (
  • {/* Case 1: Two or more logurls - Display a dropdown */} @@ -113,4 +130,5 @@ LogItem.propTypes = { logUrls: PropTypes.arrayOf(PropTypes.shape({})).isRequired, logViewerUrl: PropTypes.string, logViewerFullUrl: PropTypes.string, + taskExpired: PropTypes.bool, }; diff --git a/ui/job-view/details/summary/LogUrls.jsx b/ui/job-view/details/summary/LogUrls.jsx index f3862e746fa..cc3cde53303 100644 --- a/ui/job-view/details/summary/LogUrls.jsx +++ b/ui/job-view/details/summary/LogUrls.jsx @@ -8,7 +8,13 @@ import logviewerIcon from '../../../img/logviewerIcon.svg'; import LogItem from './LogItem'; export default function LogUrls(props) { - const { logUrls, rawLogUrls = [], logViewerUrl = null, logViewerFullUrl = null } = props; + const { + logUrls, + rawLogUrls = [], + logViewerUrl = null, + logViewerFullUrl = null, + taskExpired = false, + } = props; const logUrlsUseful = logUrls.filter( (logUrl) => !logUrl.name.includes('perfherder-data'), ); @@ -25,6 +31,7 @@ export default function LogUrls(props) { logViewerFullUrl={logViewerFullUrl} logKey="logviewer" logDescription="log viewer" + taskExpired={taskExpired} > Logviewer @@ -36,6 +43,7 @@ export default function LogUrls(props) { logViewerFullUrl={logViewerFullUrl} logKey="rawlog" logDescription="raw log" + taskExpired={taskExpired} > State: {selectedJobFull.state} + {taskExpired && ( +
    + + Expired + +
    + )}
  • ); } StatusPanel.propTypes = { selectedJobFull: PropTypes.shape({}).isRequired, + taskExpired: PropTypes.bool, }; export default StatusPanel; diff --git a/ui/job-view/details/summary/SummaryPanel.jsx b/ui/job-view/details/summary/SummaryPanel.jsx index 25ad5d6214b..935f389792b 100644 --- a/ui/job-view/details/summary/SummaryPanel.jsx +++ b/ui/job-view/details/summary/SummaryPanel.jsx @@ -7,93 +7,102 @@ import ActionBar from './ActionBar'; import ClassificationsPanel from './ClassificationsPanel'; import StatusPanel from './StatusPanel'; -class SummaryPanel extends React.PureComponent { - render() { - const { - selectedJobFull, - latestClassification = null, - bugs, - jobLogUrls = [], - jobDetails = [], - logViewerUrl = null, - logViewerFullUrl = null, - logParseStatus = 'pending', - user, - currentRepo, - classificationMap, - } = this.props; +function SummaryPanel({ + selectedJobFull, + latestClassification = null, + bugs, + jobLogUrls = [], + jobDetails = [], + logViewerUrl = null, + logViewerFullUrl = null, + logParseStatus = 'pending', + user, + currentRepo, + classificationMap, + taskExpired = false, +}) { + const logs = jobLogUrls.filter( + (log) => !log.name.includes('perfherder-data'), + ); + const artifacts = jobLogUrls.filter((artifact) => + artifact.name.includes('perfherder-data'), + ); - const logs = jobLogUrls.filter( - (log) => !log.name.includes('perfherder-data'), - ); - const artifacts = jobLogUrls.filter((artifact) => - artifact.name.includes('perfherder-data'), - ); - const logStatus = [ - { - title: 'Log parsing status', - value: !logs.length - ? 'No logs' - : logs.map((log) => log.parse_status).join(', '), - }, - ]; - const artifactStatus = [ - { - title: 'Artifact parsing status', - value: !artifacts.length ? 'No artifacts' : null, - subfields: artifacts.length - ? artifacts.map((artifact) => ({ - name: artifact.name, - value: artifact.parse_status, - })) - : null, - }, - ]; + let logParsingValue; + if (taskExpired) { + logParsingValue = 'Expired, not available'; + } else if (!logs.length) { + logParsingValue = 'No logs'; + } else { + logParsingValue = logs.map((log) => log.parse_status).join(', '); + } + const logStatus = [ + { + title: 'Log parsing status', + value: logParsingValue, + }, + ]; + + const artifactStatus = [ + { + title: 'Artifact parsing status', + value: !artifacts.length ? 'No artifacts' : null, + subfields: artifacts.length + ? artifacts.map((artifact) => ({ + name: artifact.name, + value: artifact.parse_status, + })) + : null, + }, + ]; - return ( -
    - {!!selectedJobFull && ( - <> - -
    -
      - {latestClassification && ( - - )} - - + {!!selectedJobFull && ( + <> + +
      +
        + {latestClassification && ( + -
      -
      - - )} -
    - ); - } + )} + + + +
    + + )} + + ); } SummaryPanel.propTypes = { @@ -114,6 +123,7 @@ SummaryPanel.propTypes = { logParseStatus: PropTypes.string, logViewerUrl: PropTypes.string, logViewerFullUrl: PropTypes.string, + taskExpired: PropTypes.bool, }; -export default SummaryPanel; +export default React.memo(SummaryPanel); diff --git a/ui/job-view/details/useJobDetails.js b/ui/job-view/details/useJobDetails.js index 20bec848706..b9ecc28a806 100644 --- a/ui/job-view/details/useJobDetails.js +++ b/ui/job-view/details/useJobDetails.js @@ -16,16 +16,26 @@ import { Perfdocs } from '../../perfherder/perf-helpers/perfdocs'; // Debounce delay for loading job details when rapidly switching jobs const JOB_DETAILS_DEBOUNCE_MS = 200; -const fetchTaskData = async (taskId, rootUrl) => { +export const fetchTaskData = async (taskId, rootUrl) => { let testGroups = []; let taskQueueId = null; if (!taskId || !rootUrl) { - return { testGroups, taskQueueId }; + return { testGroups, taskQueueId, taskExpired: false }; } const queue = new Queue({ rootUrl }); - const taskDefinition = await queue.task(taskId); + let taskDefinition; + try { + taskDefinition = await queue.task(taskId); + } catch (error) { + // Task definition may be unavailable (e.g. expired in Taskcluster). + // Fall back to defaults so the rest of the details panel can still render, + // and flag the task as expired so the UI can communicate the degraded state. + // eslint-disable-next-line no-console + console.error('Error fetching Taskcluster task definition:', error); + return { testGroups, taskQueueId, taskExpired: true }; + } if (taskDefinition) { taskQueueId = taskDefinition.taskQueueId; if (taskDefinition.payload.env?.MOZHARNESS_TEST_PATHS) { @@ -45,7 +55,7 @@ const fetchTaskData = async (taskId, rootUrl) => { } } - return { testGroups, taskQueueId }; + return { testGroups, taskQueueId, taskExpired: false }; }; const fetchClassifications = async (jobId, signal) => { @@ -132,6 +142,7 @@ function useJobDetails(selectedJob, currentRepo, pushList, frameworks) { const [classifications, setClassifications] = useState([]); const [testGroups, setTestGroups] = useState([]); const [bugs, setBugs] = useState([]); + const [taskExpired, setTaskExpired] = useState(false); // Refs for cleanup const abortControllerRef = useRef(null); @@ -180,6 +191,7 @@ function useJobDetails(selectedJob, currentRepo, pushList, frameworks) { // If no job is selected, clear the state if (!selectedJob) { setSelectedJobFull(null); + setTaskExpired(false); previousJobIdRef.current = null; isFirstLoadRef.current = true; return; @@ -304,6 +316,7 @@ function useJobDetails(selectedJob, currentRepo, pushList, frameworks) { setLogViewerFullUrl(fullLogUrl); setJobRevision(push ? push.revision : null); setTestGroups(taskData.testGroups); + setTaskExpired(taskData.taskExpired); setClassifications(classificationsResult.classifications); setBugs(classificationsResult.bugs); @@ -440,6 +453,7 @@ function useJobDetails(selectedJob, currentRepo, pushList, frameworks) { classifications, testGroups, bugs, + taskExpired, }; } diff --git a/ui/logviewer/ClassicLogViewer.jsx b/ui/logviewer/ClassicLogViewer.jsx index 67b6fce45bf..cfe973831bc 100644 --- a/ui/logviewer/ClassicLogViewer.jsx +++ b/ui/logviewer/ClassicLogViewer.jsx @@ -21,6 +21,7 @@ const ClassicLogViewer = ({ lineCount, isLoading, error, + errorStatus, searchTerm, setSearchTerm, matchLineNumbers, @@ -188,6 +189,13 @@ const ClassicLogViewer = ({ ); if (error) { + if (errorStatus === 404) { + return ( +
    + This log has expired and is no longer available. +
    + ); + } return
    Error loading log: {error}
    ; } diff --git a/ui/logviewer/useLogViewer.js b/ui/logviewer/useLogViewer.js index b33b219824b..3e5ac89b962 100644 --- a/ui/logviewer/useLogViewer.js +++ b/ui/logviewer/useLogViewer.js @@ -13,6 +13,7 @@ export function useLogViewer({ url, caseInsensitive = true } = {}) { const [lines, setLines] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + const [errorStatus, setErrorStatus] = useState(null); const [searchTerm, setSearchTermState] = useState(''); const [matchLineNumbers, setMatchLineNumbers] = useState([]); @@ -38,19 +39,23 @@ export function useLogViewer({ url, caseInsensitive = true } = {}) { setLines([]); setIsLoading(false); setError(null); + setErrorStatus(null); return; } let cancelled = false; setIsLoading(true); setError(null); + setErrorStatus(null); fetch(url) .then((response) => { if (!response.ok) { - throw new Error( + const err = new Error( `Failed to fetch log: ${response.status} ${response.statusText}`, ); + err.status = response.status; + throw err; } return response.text(); }) @@ -63,6 +68,7 @@ export function useLogViewer({ url, caseInsensitive = true } = {}) { .catch((err) => { if (cancelled) return; setError(err.message); + setErrorStatus(err.status ?? null); setLines([]); setIsLoading(false); }); @@ -264,6 +270,7 @@ export function useLogViewer({ url, caseInsensitive = true } = {}) { lineCount, isLoading, error, + errorStatus, // Search searchTerm, setSearchTerm, @@ -294,6 +301,7 @@ export function useLogViewer({ url, caseInsensitive = true } = {}) { lineCount, isLoading, error, + errorStatus, searchTerm, setSearchTerm, matchLineNumbers,