ui/perfherder/alerts/AlertTableRow.jsx (559 lines of code) (raw):

import React from 'react'; import PropTypes from 'prop-types'; import { Button, FormGroup, Input, Label } from 'reactstrap'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faStar as faStarSolid, faUser, faCheck, faChartLine, faCirclePlay, faFire, faPlus, } from '@fortawesome/free-solid-svg-icons'; import { faStar as faStarRegular } from '@fortawesome/free-regular-svg-icons'; import { Link } from 'react-router-dom'; import Badge from 'reactstrap/lib/Badge'; import { getPerfCompareBaseSubtestsURL } from '../../helpers/url'; import { getStatus, getGraphsURL, modifyAlert, formatNumber, getFrameworkName, getTimeRange, getSideBySideLink, } from '../perf-helpers/helpers'; import SimpleTooltip from '../../shared/SimpleTooltip'; import { alertStatusMap, alertBackfillResultStatusMap, alertBackfillResultVisual, backfillRetriggeredTitle, noiseProfiles, browsertimeId, browsertimeEssentialTests, browsertimeBenchmarksTests, } from '../perf-helpers/constants'; import { Perfdocs } from '../perf-helpers/perfdocs'; import AlertTablePlatform from './AlertTablePlatform'; import AlertTableTagsOptions from './AlertTableTagsOptions'; import Magnitude from './Magnitude'; import BadgeTooltip from './BadgeTooltip'; export default class AlertTableRow extends React.Component { constructor(props) { super(props); this.state = { starred: this.props.alert.starred, checkboxSelected: false, icons: [], }; } componentDidMount() { const { alert } = this.props; this.showCriticalMagnitudeIcons(alert); } componentDidUpdate(prevProps) { const { selectedAlerts, alert } = this.props; // reset alert checkbox when an action is taken in the AlertActionPanel // (it resets selectedAlerts) or an individual alert has been deselected // and removed from selectedAlerts if (prevProps.selectedAlerts !== selectedAlerts) { if (!selectedAlerts.length) { this.setState({ checkboxSelected: false }); } else { const index = selectedAlerts.findIndex((item) => item.id === alert.id); this.setState({ checkboxSelected: index !== -1 }); } } } toggleStar = async () => { const { starred } = this.state; const { alert, fetchAlertSummaries, alertSummary } = this.props; const updatedStar = { starred: !starred, }; // passed as prop only for testing purposes const { data, failureStatus } = await this.props.modifyAlert( alert, updatedStar, ); if (!failureStatus) { // now refresh UI, by syncing with backend fetchAlertSummaries(alertSummary.id); } else { return this.props.updateViewState({ errorMessages: [`Failed to update alert ${alert.id}: ${data}`], }); } this.setState(updatedStar); }; getReassignment = (alert) => { let text = 'to'; let alertId = alert.related_summary_id; if (alert.related_summary_id === this.props.alertSummary.id) { text = 'from'; alertId = alert.summary_id; } return ( <span> {` ${text} `} <Link to={`./alerts?id=${alertId}`} className="text-darker-info" >{`alert #${alertId}`}</Link> </span> ); }; updateCheckbox = () => { const { updateSelectedAlerts, selectedAlerts, alert } = this.props; const { checkboxSelected } = this.state; const index = selectedAlerts.findIndex((item) => item.id === alert.id); if (checkboxSelected && index === -1) { return updateSelectedAlerts({ selectedAlerts: [...selectedAlerts, alert], }); } if (!checkboxSelected && index !== -1) { selectedAlerts.splice(index, 1); return updateSelectedAlerts({ selectedAlerts, allSelected: false }); } }; renderAlertStatus = (alert, alertStatus, statusColor) => { return ( <React.Fragment> ( {statusColor === 'text-success' && ( <FontAwesomeIcon icon={faCheck} color="#28a745" /> )} <span className={statusColor}>{alertStatus}</span> {alert.related_summary_id && this.getReassignment(alert)}) </React.Fragment> ); }; getBackfillStatusInfo = (alert) => { if (!alert.backfill_record || alert.backfill_record.status === undefined) return null; const statusesToDisplayTasksCount = ['backfilled', 'successful', 'failed']; const backfillStatus = getStatus( alert.backfill_record.status, alertBackfillResultStatusMap, ); const alertBackfillStatus = alertBackfillResultVisual[backfillStatus]; // Added only for testing locally the UI changes // To be removed once this is in production alertBackfillStatus.backfillsFailed = alert.backfill_record.total_backfills_failed || 0; alertBackfillStatus.backfillsSuccessful = alert.backfill_record.total_backfills_successful || 0; alertBackfillStatus.backfillsInProgress = alert.backfill_record.total_backfills_in_progress || 0; if ( statusesToDisplayTasksCount.includes(backfillStatus) && // the next checks are here to not confuse users // since we won't have count for tasks right away // to be removed after changes are in prod (alertBackfillStatus.backfillsFailed !== 0 || alertBackfillStatus.backfillsInProgress !== 0 || alertBackfillStatus.backfillsSuccessful !== 0) ) alertBackfillStatus.displayTasksCount = true; return alertBackfillStatus; }; getTitleText = (alert, alertStatus) => { const { framework, id } = this.props.alertSummary; const { frameworks } = this.props; let statusColor = ''; let textEffect = ''; if (alertStatus === 'invalid') { statusColor = 'text-danger'; } if (alertStatus === 'untriaged') { statusColor = 'text-warning'; } if ( alertStatus === 'invalid' || (alert.related_summary_id && alert.related_summary_id !== id) ) { textEffect = 'strike-through'; } const frameworkName = getFrameworkName(frameworks, framework); const { title } = alert; const { suite, test, machine_platform: platform } = alert.series_signature; const perfdocs = new Perfdocs(frameworkName, suite, platform, title); const hasDocumentation = perfdocs.hasDocumentation(); const duplicatedName = suite === test; return ( <div className="alert-title-container"> <div className={`alert-title ${textEffect}`} id={`alert ${alert.id} title`} title={alert.backfill_record ? backfillRetriggeredTitle : ''} > {hasDocumentation && alert.title ? ( <span className="alert-docs" data-testid={`alert ${alert.id} title`} > <a data-testid="docs" href={perfdocs.documentationURL} target="_blank" rel="noopener noreferrer" > {suite} </a>{' '} {!duplicatedName && test} </span> ) : ( <span data-testid={`alert ${alert.id} title`}> {suite} {!duplicatedName && test} </span> )} </div> <div> {this.renderAlertStatus(alert, alertStatus, statusColor)}{' '} <span className="result-links"> {alert.series_signature.has_subtests && ( <a href={this.getSubtestsURL()} target="_blank" rel="noopener noreferrer" > · subtests </a> )} </span> </div> </div> ); }; // arbitrary scale from 0-20% multiplied by 5, capped // at 100 (so 20% regression === 100% bad) getCappedMagnitude = (percent) => Math.min(Math.abs(percent) * 5, 100); getSubtestsURL = () => { const { alert, alertSummary } = this.props; return getPerfCompareBaseSubtestsURL( alertSummary.repository, alertSummary.prev_push_revision, alertSummary.repository, alertSummary.revision, alertSummary.framework, alert.series_signature.id, alert.series_signature.id, ); }; buildSideBySideLink = () => { const { alert, alertSummary } = this.props; const platform = alert.series_signature.machine_platform; const { suite } = alert.series_signature; let testName = suite; if (suite in browsertimeEssentialTests) { testName = `essential ${suite}`; if ('bytecode-cached' in alert.series_signature.tags) { testName = `bytecode ${suite}`; } } const jobUrl = getSideBySideLink( alertSummary.repository, alertSummary.prev_push_revision, alertSummary.revision, platform, testName, ); return jobUrl; }; showCriticalMagnitudeIcons(alert) { const alertMagnitude = Math.round(alert.amount_pct); const alertNewValue = alert.new_value; let numberOfIcons = 0; let exceedsMaximumIcons = false; if (alert.is_regression) { if ( alertMagnitude >= 100 && alertNewValue !== 0 && alertMagnitude < 200 ) { numberOfIcons = 1; } else if (alertMagnitude >= 200 && alertMagnitude < 300) { numberOfIcons = 2; } else if (alertMagnitude === 300) { numberOfIcons = 3; } else if (alertMagnitude > 300) { numberOfIcons = 3; exceedsMaximumIcons = true; } } else if (alertMagnitude === 100 && alertNewValue === 0) { this.setState((prevState) => ({ icons: [ ...prevState.icons, <SimpleTooltip key={alert.id} text={ <FontAwesomeIcon icon={faFire} className="icon-green-flame icon" /> } tooltipText="This should be treated as a regression" />, ], })); } for (let i = 0; i < numberOfIcons; i++) { this.setState((prevState) => ({ icons: [ ...prevState.icons, <SimpleTooltip key={i} text={<FontAwesomeIcon icon={faFire} className="icon" />} tooltipText="Magnitude" />, ], })); if (exceedsMaximumIcons && i === numberOfIcons - 1) { this.setState((prevState) => ({ icons: [ ...prevState.icons, <FontAwesomeIcon key={i + 1} icon={faPlus} className="icon-plus icon" />, ], })); } } } render() { const { user, alert, alertSummary } = this.props; const { starred, checkboxSelected, icons } = this.state; const { repository, framework } = alertSummary; const { tags, extra_options: options } = alert.series_signature; const tagsAndOptions = tags.concat(options); const stripDuplicates = new Set(tagsAndOptions.filter((item) => item)); const items = Array.from(stripDuplicates).map((element) => ({ name: element, tag: tags.includes(element), option: options.includes(element), tagAndOption: tags.includes(element) && options.includes(element), })); const timeRange = getTimeRange(alertSummary); const alertStatus = getStatus(alert.status, alertStatusMap); const tooltipText = alert.classifier_email ? `Classified by ${alert.classifier_email}` : 'Classified automatically'; const bookmarkClass = starred ? 'visible' : ''; const noiseProfile = alert.noise_profile || 'N\\A'; const noiseProfileTooltip = alert.noise_profile ? noiseProfiles[alert.noise_profile.replace('/', '')] : noiseProfiles.NA; // TODO: make a side-by-side status of its own. We know that side-by-side was triggered // if only backfill bot has one of the three statuses below const backfillResultStatuses = [ alertBackfillResultStatusMap.backfilled, alertBackfillResultStatusMap.successful, alertBackfillResultStatusMap.failed, ]; const sxsTriggered = alert.backfill_record && backfillResultStatuses.includes(alert.backfill_record.status); const showSideBySideLink = alert.series_signature.framework_id === browsertimeId && !alert.series_signature.tags.includes('interactive') && !browsertimeBenchmarksTests.includes(alert.series_signature.suite) && sxsTriggered; const backfillStatusInfo = this.getBackfillStatusInfo(alert); let sherlockTooltip = backfillStatusInfo && backfillStatusInfo.message; if (backfillStatusInfo && backfillStatusInfo.displayTasksCount) { sherlockTooltip = ( <> <i>{backfillStatusInfo.message}</i> <br /> In progress: {backfillStatusInfo.backfillsInProgress} <br /> Successful: {backfillStatusInfo.backfillsSuccessful} <br /> Failed: {backfillStatusInfo.backfillsFailed} <br /> </> ); } return ( <tr className={ alertSummary.notes ? 'border-top border-left border-right' : 'border' } aria-label="Alert table row" data-testid={alert.id} > <td className="table-width-xs px-1"> <FormGroup check className="ml-2 pl-4"> <Label hidden>alert {alert.id} title</Label> <Input aria-label={`alert ${alert.id} title`} data-testid={`alert ${alert.id} checkbox`} type="checkbox" disabled={!user.isStaff} checked={checkboxSelected} onChange={() => this.setState( { checkboxSelected: !checkboxSelected }, this.updateCheckbox, ) } /> </FormGroup> </td> <td className="px-0 d-flex flex-column align-items-start border-top-0"> <Button color="black" aria-label={ starred ? 'Remove bookmark from this Alert' : 'Bookmark this Alert' } className={`${bookmarkClass} border p-0 border-0 bg-transparent`} data-testid={`alert ${alert.id.toString()} star`} onClick={this.toggleStar} > <FontAwesomeIcon title={starred ? 'starred' : 'not starred'} icon={starred ? faStarSolid : faStarRegular} /> </Button> <a href={getGraphsURL(alert, timeRange, repository, framework)} target="_blank" rel="noopener noreferrer" className="text-dark button btn border p-0 border-0 bg-transparent" aria-label="graph-link" > <FontAwesomeIcon title="Open graph" icon={faChartLine} /> </a> </td> <td className="text-left"> {alertStatus !== 'untriaged' ? ( <SimpleTooltip text={this.getTitleText(alert, alertStatus)} tooltipText={tooltipText} /> ) : ( this.getTitleText(alert, alertStatus) )} {backfillStatusInfo && ( <span className="text-darker-info"> <SimpleTooltip key={alert.id} text={ <FontAwesomeIcon icon={backfillStatusInfo.icon} color={backfillStatusInfo.color} data-testid={`alert ${alert.id.toString()} sherlock icon`} /> } tooltipText={sherlockTooltip} /> </span> )} </td> <td className="table-width-lg"> <div className="information-container"> <AlertTablePlatform platform={alert.series_signature.machine_platform} /> </div> </td> {alertSummary.framework === browsertimeId && ( <td className="table-width-md"> {showSideBySideLink ? ( <span className="text-darker-info"> <a href={this.buildSideBySideLink()} target="_blank" rel="noopener noreferrer" className="text-dark button btn border p-0 border-0 bg-transparent" aria-label="side-by-side" > <FontAwesomeIcon title="Open side-by-side link" icon={faCirclePlay} data-testid={`alert ${alert.id.toString()} side-by-side icon`} /> </a> </span> ) : ( <Badge className="mb-1" color="light"> None </Badge> )} </td> )} <td className="table-width-lg"> <div className="information-container"> <div className="option"> <BadgeTooltip textClass="detail-hint" text={noiseProfile} tooltipText={noiseProfileTooltip} autohide={false} /> </div> {icons.length > 0 ? ( <div className="option" data-testid="flame-icons"> {icons} </div> ) : ( '' )} </div> </td> <td className="table-width-lg tags-and-options-td"> <AlertTableTagsOptions alertId={alert.id} items={items} /> </td> <td className="table-width-md"> <Magnitude alert={alert} /> </td> <td className="table-width-sm"> <SimpleTooltip textClass="detail-hint" text={ alert.manually_created ? ( <FontAwesomeIcon title="Alert created by a Sheriff" icon={faUser} /> ) : ( formatNumber(alert.t_value) ) } tooltipText={ alert.manually_created ? 'Alert created by a Sheriff' : 'Confidence value as calculated by Perfherder alerts. Note that this is NOT the same as the calculation used in the compare view' } /> </td> </tr> ); } } AlertTableRow.propTypes = { alertSummary: PropTypes.shape({ repository: PropTypes.string, framework: PropTypes.number, id: PropTypes.number, }).isRequired, user: PropTypes.shape({}), alert: PropTypes.shape({ starred: PropTypes.bool, }).isRequired, updateSelectedAlerts: PropTypes.func.isRequired, selectedAlerts: PropTypes.arrayOf(PropTypes.shape({})).isRequired, updateViewState: PropTypes.func.isRequired, modifyAlert: PropTypes.func, }; AlertTableRow.defaultProps = { user: null, modifyAlert, };