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,