diff --git a/report-app/src/app/pages/report-viewer/repair-attempt-graph-builder.ts b/report-app/src/app/pages/report-viewer/repair-attempt-graph-builder.ts new file mode 100644 index 0000000..751d33b --- /dev/null +++ b/report-app/src/app/pages/report-viewer/repair-attempt-graph-builder.ts @@ -0,0 +1,108 @@ +import {RunInfoFromReportServer} from '../../../../../runner/shared-interfaces'; +import {BuildResultStatus} from '../../../../../runner/workers/builder/builder-types'; +import {ScoreCssVariable} from '../../shared/scoring'; +import {StackedBarChartData} from '../../shared/visualization/stacked-bar-chart/stacked-bar-chart'; + +/** + * Calculates the average number of repair attempts performed in a run. + */ +export function calculateAverageRepairAttempts(report: RunInfoFromReportServer) { + let totalRepairs = 0; + let count = 0; + + for (const result of report.results) { + // Only consider successful builds that required repairs. + if ( + result.finalAttempt.buildResult.status === BuildResultStatus.SUCCESS && + result.repairAttempts > 0 + ) { + totalRepairs += result.repairAttempts; + count++; + } + } + + return count > 0 ? totalRepairs / count : null; +} + +/** + * Creates graph data for the "repair attempt" graph, from a given run report. + */ +export function createRepairAttemptGraphData(report: RunInfoFromReportServer) { + const repairsToAppCount = new Map(); + + // Map repair count to how many applications shared that count. + let maxRepairCount = 0; + for (const result of report.results) { + if (result.finalAttempt.buildResult.status === BuildResultStatus.ERROR) { + repairsToAppCount.set('failed', (repairsToAppCount.get('failed') || 0) + 1); + } else { + const repairs = result.repairAttempts; + // For this graph, we ignore applications that required no repair. + if (repairs > 0) { + repairsToAppCount.set(repairs, (repairsToAppCount.get(repairs) || 0) + 1); + maxRepairCount = Math.max(maxRepairCount, repairs); + } + } + } + + const data: StackedBarChartData = []; + + // All the numeric keys, sorted by value. + const intermediateRepairKeys = Array.from(repairsToAppCount.keys()) + .filter((k): k is number => typeof k === 'number') + .sort((a, b) => a - b); + + // This graph might involve a bunch of sections. We want to scale them among all the possible color "grades". + + for (let repairCount = 1; repairCount <= maxRepairCount; repairCount++) { + const applicationCount = repairsToAppCount.get(repairCount); + if (!applicationCount) continue; + + data.push({ + label: labelByRepairCount(repairCount), + color: colorByRepairCount(repairCount), + value: applicationCount, + }); + } + + // Handle 'Build failed even after all retries' - always maps to the "failure" grade. + const failedCount = repairsToAppCount.get('failed') || 0; + if (failedCount > 0) { + data.push({ + label: 'Build failed even after all retries', + color: ScoreCssVariable.poor, + value: failedCount, + }); + } + return data; +} + +function labelByRepairCount(repairCount: number): string { + switch (repairCount) { + case 1: + return '1 repair'; + case 2: + case 3: + case 4: + return `${repairCount} repairs`; + default: + return '5+ repairs'; + } +} + +function colorByRepairCount(repairCount: number): string { + // We're using mediocre1-5 since these are essentially *all* bad so we don't want green in this + // graph. + switch (repairCount) { + case 1: + return ScoreCssVariable.mediocre1; + case 2: + return ScoreCssVariable.mediocre2; + case 3: + return ScoreCssVariable.mediocre3; + case 4: + return ScoreCssVariable.mediocre4; + default: + return ScoreCssVariable.mediocre5; + } +} diff --git a/report-app/src/app/pages/report-viewer/report-viewer.html b/report-app/src/app/pages/report-viewer/report-viewer.html index b035275..e3c84e3 100644 --- a/report-app/src/app/pages/report-viewer/report-viewer.html +++ b/report-app/src/app/pages/report-viewer/report-viewer.html @@ -73,6 +73,32 @@

+ @if (hasSuccessfulResultWithMoreThanOneBuildAttempt()) { +
+

+ build_circle + Repair attempts + info + @if (averageRepairAttempts() !== null) { + Avg: {{ averageRepairAttempts() | number: '1.2-2' }} + info + } +

+
+ +
+
+ } @if (overview.stats.tests) {

diff --git a/report-app/src/app/pages/report-viewer/report-viewer.scss b/report-app/src/app/pages/report-viewer/report-viewer.scss index 3673fb9..effb37f 100644 --- a/report-app/src/app/pages/report-viewer/report-viewer.scss +++ b/report-app/src/app/pages/report-viewer/report-viewer.scss @@ -190,6 +190,17 @@ lighthouse-category + lighthouse-category { align-items: center; } +.chart-title-tooltip-icon { + font-size: 18px; + cursor: help; +} + +.chart-title-right-label { + margin-left: auto; + font-size: 0.9rem; + font-weight: 500; +} + .axe-violations ul { padding: 0px 20px; } diff --git a/report-app/src/app/pages/report-viewer/report-viewer.ts b/report-app/src/app/pages/report-viewer/report-viewer.ts index beeed06..d58dc5f 100644 --- a/report-app/src/app/pages/report-viewer/report-viewer.ts +++ b/report-app/src/app/pages/report-viewer/report-viewer.ts @@ -1,19 +1,12 @@ import {Clipboard} from '@angular/cdk/clipboard'; import {DatePipe, DecimalPipe} from '@angular/common'; import {HttpClient} from '@angular/common/http'; -import { - afterNextRender, - Component, - computed, - ElementRef, - inject, - input, - resource, - signal, - viewChild, -} from '@angular/core'; +import {afterNextRender, Component, computed, inject, input, resource, signal} from '@angular/core'; import {NgxJsonViewerModule} from 'ngx-json-viewer'; -import {BuildErrorType} from '../../../../../runner/workers/builder/builder-types'; +import { + BuildErrorType, + BuildResultStatus, +} from '../../../../../runner/workers/builder/builder-types'; import { AssessmentResult, AssessmentResultFromReportServer, @@ -46,6 +39,10 @@ import {AiAssistant} from '../../shared/ai-assistant/ai-assistant'; import {LighthouseCategory} from './lighthouse-category'; import {MultiSelect} from '../../shared/multi-select/multi-select'; import {FileCodeViewer} from '../../shared/file-code-viewer/file-code-viewer'; +import { + calculateAverageRepairAttempts, + createRepairAttemptGraphData, +} from './repair-attempt-graph-builder'; const localReportRegex = /-l\d+$/; @@ -283,6 +280,39 @@ export class ReportViewer { ]; } + protected hasSuccessfulResultWithMoreThanOneBuildAttempt = computed(() => { + if (!this.selectedReport.hasValue()) { + return false; + } + for (const result of this.selectedReport.value().results) { + if ( + result.finalAttempt.buildResult.status === BuildResultStatus.SUCCESS && + result.repairAttempts > 1 + ) { + return true; + } + } + return false; + }); + + protected averageRepairAttempts = computed(() => { + const report = this.selectedReportWithSortedResults(); + if (!report) { + return null; + } + + return calculateAverageRepairAttempts(report); + }); + + protected repairAttemptsAsGraphData = computed(() => { + const report = this.selectedReportWithSortedResults(); + if (!report) { + return []; + } + + return createRepairAttemptGraphData(report); + }); + protected testsAsGraphData(tests: RunSummaryTests): StackedBarChartData { return [ { diff --git a/report-app/src/app/shared/scoring.ts b/report-app/src/app/shared/scoring.ts index f609b5e..9081694 100644 --- a/report-app/src/app/shared/scoring.ts +++ b/report-app/src/app/shared/scoring.ts @@ -8,6 +8,12 @@ export enum ScoreCssVariable { good = 'var(--status-fill-good)', poor = 'var(--status-fill-poor)', neutral = 'var(--status-fill-neutral)', + // When we need more refined gradiant between "good" and "poor". + mediocre1 = 'var(--status-fill-mediocre-1)', + mediocre2 = 'var(--status-fill-mediocre-2)', + mediocre3 = 'var(--status-fill-mediocre-3)', + mediocre4 = 'var(--status-fill-mediocre-4)', + mediocre5 = 'var(--status-fill-mediocre-5)', } const CACHED_COLORS = { diff --git a/report-app/src/app/shared/styles/tooltip.scss b/report-app/src/app/shared/styles/tooltip.scss index 7b634c3..d03df68 100644 --- a/report-app/src/app/shared/styles/tooltip.scss +++ b/report-app/src/app/shared/styles/tooltip.scss @@ -28,6 +28,7 @@ &.multiline-tooltip::before { white-space: normal; + width: max-content; max-width: 400px; } diff --git a/report-app/src/app/shared/visualization/stacked-bar-chart/stacked-bar-chart.scss b/report-app/src/app/shared/visualization/stacked-bar-chart/stacked-bar-chart.scss index e5bb19b..1602754 100644 --- a/report-app/src/app/shared/visualization/stacked-bar-chart/stacked-bar-chart.scss +++ b/report-app/src/app/shared/visualization/stacked-bar-chart/stacked-bar-chart.scss @@ -56,8 +56,9 @@ .legend { display: flex; - justify-content: center; - gap: 1.5rem; + flex-wrap: wrap; + justify-content: flex-start; + column-gap: 1.5rem; } .legend-item { @@ -66,6 +67,7 @@ font-size: 14px; color: var(--text-secondary); white-space: nowrap; + margin-top: 0.5rem; } .legend-color { diff --git a/report-app/src/styles.scss b/report-app/src/styles.scss index 9edee23..68341d6 100644 --- a/report-app/src/styles.scss +++ b/report-app/src/styles.scss @@ -32,6 +32,13 @@ --status-fill-poor: #ef4444; --status-fill-neutral: #aaa; + /* When we need a more gradiant spread of "meh". */ + --status-fill-mediocre-1: #fbbc04; /* Yellow 500 */ + --status-fill-mediocre-2: #f9ab00; /* Yellow 600 */ + --status-fill-mediocre-3: #f29900; /* Yellow 700 */ + --status-fill-mediocre-4: #ea8600; /* Yellow 800 */ + --status-fill-mediocre-5: #e37400; /* Yellow 900 */ + --status-text-excellent: #0c855d; --status-text-great: #0c855d; // TODO: do we want to differentiate from `excellent`? --status-text-good: #c57f08;