From cde9dd74ffbe0e567fd39ce49b12d03373af0770 Mon Sep 17 00:00:00 2001 From: Alon Mishne Date: Thu, 11 Dec 2025 14:36:47 -0800 Subject: [PATCH 1/2] feat: add repair attempt graph --- .../pages/report-viewer/report-viewer.html | 26 ++++ .../pages/report-viewer/report-viewer.scss | 11 ++ .../app/pages/report-viewer/report-viewer.ts | 115 +++++++++++++++++- report-app/src/app/shared/styles/tooltip.scss | 1 + .../stacked-bar-chart/stacked-bar-chart.scss | 6 +- report-app/src/styles.scss | 11 ++ 6 files changed, 167 insertions(+), 3 deletions(-) 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 b035275e..e3c84e33 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 3673fb9b..effb37f1 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 beeed06b..a5d63dce 100644 --- a/report-app/src/app/pages/report-viewer/report-viewer.ts +++ b/report-app/src/app/pages/report-viewer/report-viewer.ts @@ -13,7 +13,10 @@ import { viewChild, } 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, @@ -283,6 +286,116 @@ 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; + } + + 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; + }); + + protected repairAttemptsAsGraphData = computed(() => { + const report = this.selectedReportWithSortedResults(); + if (!report) { + return []; + } + + 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". + + const minGrade = 1; + const maxGrade = 8; + const failureGrade = 9; + + for (let repairCount = 1; repairCount <= maxRepairCount; repairCount++) { + const applicationCount = repairsToAppCount.get(repairCount); + if (!applicationCount) continue; + const label = `${repairCount} repair${repairCount > 1 ? 's' : ''}`; + + // Normalize the repair count to the range [0, 1]. + const normalizedRepairCount = (repairCount - 1) / (maxRepairCount - 1); + + let gradeIndex: number; + if (intermediateRepairKeys.length === 1) { + // If there's only one intermediate repair count, map it to a middle grade (e.g., --chart-grade-5) + gradeIndex = Math.floor(maxGrade / 2) + minGrade; + } else { + // Distribute multiple intermediate repair counts evenly across available grades + gradeIndex = minGrade + Math.round(normalizedRepairCount * (maxGrade - minGrade)); + } + + data.push({ + label, + color: `var(--chart-grade-${gradeIndex})`, + 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: `var(--chart-grade-${failureGrade})`, + value: failedCount, + }); + } + return data; + }); + protected testsAsGraphData(tests: RunSummaryTests): StackedBarChartData { return [ { diff --git a/report-app/src/app/shared/styles/tooltip.scss b/report-app/src/app/shared/styles/tooltip.scss index 7b634c38..d03df688 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 e5bb19b2..16027543 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 9edee237..d76ea5ae 100644 --- a/report-app/src/styles.scss +++ b/report-app/src/styles.scss @@ -38,6 +38,17 @@ --status-text-poor: #eb1515; --status-text-neutral: #64748b; + /* 10-step Green-to-Red Quality Gradient */ + --chart-grade-1: #10b981; /* Emerald 500 (Excellent) */ + --chart-grade-2: #22c55e; /* Green 500 */ + --chart-grade-3: #4ade80; /* Green 400 */ + --chart-grade-4: #84cc16; /* Lime 500 (Great) */ + --chart-grade-5: #a3e635; /* Lime 400 */ + --chart-grade-6: #facc15; /* Yellow 400 */ + --chart-grade-7: #f59e0b; /* Amber 500 (Good) */ + --chart-grade-8: #f97316; /* Orange 500 */ + --chart-grade-9: #ef4444; /* Red 500 (Poor) */ + --tooltip-background-color: light-dark(#111827, #f1f4f9); --tooltip-text-color: light-dark(#f9fafb, #1e293b); From 7250acd22606295e9e061fd13e0fa43e6669c3e7 Mon Sep 17 00:00:00 2001 From: Alon Mishne Date: Mon, 15 Dec 2025 14:16:34 -0800 Subject: [PATCH 2/2] refactor: change code location and color gradiant of repair attempt graph --- .../repair-attempt-graph-builder.ts | 108 ++++++++++++++++++ .../app/pages/report-viewer/report-viewer.ts | 97 ++-------------- report-app/src/app/shared/scoring.ts | 6 + report-app/src/styles.scss | 18 ++- 4 files changed, 128 insertions(+), 101 deletions(-) create mode 100644 report-app/src/app/pages/report-viewer/repair-attempt-graph-builder.ts 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 00000000..751d33b1 --- /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.ts b/report-app/src/app/pages/report-viewer/report-viewer.ts index a5d63dce..d58dc5f4 100644 --- a/report-app/src/app/pages/report-viewer/report-viewer.ts +++ b/report-app/src/app/pages/report-viewer/report-viewer.ts @@ -1,17 +1,7 @@ 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, @@ -49,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+$/; @@ -307,21 +301,7 @@ export class ReportViewer { return null; } - 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; + return calculateAverageRepairAttempts(report); }); protected repairAttemptsAsGraphData = computed(() => { @@ -330,70 +310,7 @@ export class ReportViewer { return []; } - 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". - - const minGrade = 1; - const maxGrade = 8; - const failureGrade = 9; - - for (let repairCount = 1; repairCount <= maxRepairCount; repairCount++) { - const applicationCount = repairsToAppCount.get(repairCount); - if (!applicationCount) continue; - const label = `${repairCount} repair${repairCount > 1 ? 's' : ''}`; - - // Normalize the repair count to the range [0, 1]. - const normalizedRepairCount = (repairCount - 1) / (maxRepairCount - 1); - - let gradeIndex: number; - if (intermediateRepairKeys.length === 1) { - // If there's only one intermediate repair count, map it to a middle grade (e.g., --chart-grade-5) - gradeIndex = Math.floor(maxGrade / 2) + minGrade; - } else { - // Distribute multiple intermediate repair counts evenly across available grades - gradeIndex = minGrade + Math.round(normalizedRepairCount * (maxGrade - minGrade)); - } - - data.push({ - label, - color: `var(--chart-grade-${gradeIndex})`, - 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: `var(--chart-grade-${failureGrade})`, - value: failedCount, - }); - } - return data; + return createRepairAttemptGraphData(report); }); protected testsAsGraphData(tests: RunSummaryTests): StackedBarChartData { diff --git a/report-app/src/app/shared/scoring.ts b/report-app/src/app/shared/scoring.ts index f609b5ec..9081694f 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/styles.scss b/report-app/src/styles.scss index d76ea5ae..68341d6d 100644 --- a/report-app/src/styles.scss +++ b/report-app/src/styles.scss @@ -32,23 +32,19 @@ --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; --status-text-poor: #eb1515; --status-text-neutral: #64748b; - /* 10-step Green-to-Red Quality Gradient */ - --chart-grade-1: #10b981; /* Emerald 500 (Excellent) */ - --chart-grade-2: #22c55e; /* Green 500 */ - --chart-grade-3: #4ade80; /* Green 400 */ - --chart-grade-4: #84cc16; /* Lime 500 (Great) */ - --chart-grade-5: #a3e635; /* Lime 400 */ - --chart-grade-6: #facc15; /* Yellow 400 */ - --chart-grade-7: #f59e0b; /* Amber 500 (Good) */ - --chart-grade-8: #f97316; /* Orange 500 */ - --chart-grade-9: #ef4444; /* Red 500 (Poor) */ - --tooltip-background-color: light-dark(#111827, #f1f4f9); --tooltip-text-color: light-dark(#f9fafb, #1e293b);