From 3489fafc01e8efdc8bd06741748e99f6f7b79ec0 Mon Sep 17 00:00:00 2001 From: Ihor Mykhno Date: Tue, 16 Jun 2026 15:43:30 +0200 Subject: [PATCH 1/5] refactor (scorecard): aggregation KPI type from `average` to `weightedStatusScore` Signed-off-by: Ihor Mykhno --- .../.changeset/stupid-knives-wonder.md | 20 ++++++++ workspaces/scorecard/README.md | 14 ++--- .../scorecard/app-config.local.EXAMPLE.yaml | 4 +- workspaces/scorecard/app-config.yaml | 4 +- .../app-legacy/e2e-tests/scorecard.test.ts | 28 +++++----- .../app-legacy/e2e-tests/utils/apiUtils.ts | 3 +- .../e2e-tests/utils/scorecardResponseUtils.ts | 22 ++++---- .../e2e-tests/utils/translationUtils.ts | 12 ++--- ...s => weightedStatusScoreCardAssertions.ts} | 51 ++++++++++++------- .../plugins/scorecard-backend/README.md | 24 ++++----- .../plugins/scorecard-backend/config.d.ts | 4 +- .../scorecard-backend/docs/aggregation.md | 32 ++++++------ .../scorecard-backend/docs/drill-down.md | 2 +- .../scorecard-backend/docs/thresholds.md | 14 ++--- .../src/constants/aggregationKPIs.ts | 41 +++++++-------- .../aggregations/AggregationsService.test.ts | 31 ++++++----- ...WeightedStatusScoreAggregationStrategy.ts} | 49 +++++++++--------- .../strategies/registerStrategies.test.ts | 8 +-- .../strategies/registerStrategies.ts | 7 ++- ...tedStatusScoreAggregationStrategy.test.ts} | 46 +++++++++-------- .../src/service/mappers.test.ts | 20 ++++---- .../src/service/router.test.ts | 6 +-- .../src/utils/buildAggregationConfig.test.ts | 16 +++--- .../src/utils/buildAggregationConfig.ts | 2 +- .../schemas/aggregationConfigSchemas.ts | 6 +-- .../validateAggregationConfig.test.ts | 34 ++++++------- .../validation/validateAggregationConfig.ts | 2 +- .../plugins/scorecard-common/report.api.md | 21 ++++---- .../src/constants/aggregations.ts | 4 +- .../scorecard-common/src/types/aggregation.ts | 17 ++++--- .../scorecard/plugins/scorecard/README.md | 8 +-- .../scorecard/__fixtures__/scorecardData.ts | 12 ++--- .../plugins/scorecard/report-alpha.api.md | 14 ++--- .../scorecard/plugins/scorecard/report.api.md | 14 ++--- .../AggregatedMetricCard.tsx | 14 +++-- .../DonutChartTooltipContent.tsx | 6 +-- .../LegendTooltipContent.tsx | 4 +- .../TooltipContent.tsx | 0 .../WeightedStatusScoreCardComponent.tsx} | 41 ++++++++------- ...WeightedStatusScoreCardPieCenterLabel.tsx} | 10 ++-- .../types.ts | 6 +-- .../components/CardLegendContent.tsx | 2 +- .../components/CardTooltip.tsx | 2 +- .../__tests__/CustomLegend.test.tsx | 22 ++++---- .../__tests__/ScorecardHomepageCard.test.tsx | 30 +++++------ .../plugins/scorecard/src/translations/de.ts | 16 +++--- .../plugins/scorecard/src/translations/es.ts | 16 +++--- .../plugins/scorecard/src/translations/fr.ts | 14 ++--- .../plugins/scorecard/src/translations/it.ts | 16 +++--- .../plugins/scorecard/src/translations/ja.ts | 14 ++--- .../plugins/scorecard/src/translations/ref.ts | 15 +++--- 51 files changed, 443 insertions(+), 377 deletions(-) create mode 100644 workspaces/scorecard/.changeset/stupid-knives-wonder.md rename workspaces/scorecard/packages/app-legacy/e2e-tests/utils/{averageCardAssertions.ts => weightedStatusScoreCardAssertions.ts} (69%) rename workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/{AverageAggregationStrategy.ts => WeightedStatusScoreAggregationStrategy.ts} (76%) rename workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/{averageAggregationStrategy.test.ts => weightedStatusScoreAggregationStrategy.test.ts} (78%) rename workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/{AverageCard => WeightedStatusScoreCard}/DonutChartTooltipContent.tsx (91%) rename workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/{AverageCard => WeightedStatusScoreCard}/LegendTooltipContent.tsx (91%) rename workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/{AverageCard => WeightedStatusScoreCard}/TooltipContent.tsx (100%) rename workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/{AverageCard/AverageCardComponent.tsx => WeightedStatusScoreCard/WeightedStatusScoreCardComponent.tsx} (76%) rename workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/{AverageCard/AverageCardPieCenterLabel.tsx => WeightedStatusScoreCard/WeightedStatusScoreCardPieCenterLabel.tsx} (85%) rename workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/{AverageCard => WeightedStatusScoreCard}/types.ts (86%) diff --git a/workspaces/scorecard/.changeset/stupid-knives-wonder.md b/workspaces/scorecard/.changeset/stupid-knives-wonder.md new file mode 100644 index 0000000000..84f344415f --- /dev/null +++ b/workspaces/scorecard/.changeset/stupid-knives-wonder.md @@ -0,0 +1,20 @@ +--- +'@red-hat-developer-hub/backstage-plugin-scorecard-backend': minor +'@red-hat-developer-hub/backstage-plugin-scorecard-common': minor +'@red-hat-developer-hub/backstage-plugin-scorecard': minor +--- + +Rename aggregation KPI type `average` to `weightedStatusScore`. + +**Breaking changes** + +### App config + +- `scorecard.aggregationKPIs.*.type`: `average` → `weightedStatusScore` + +### `GET /aggregations/:aggregationId` API + +- `metadata.aggregationType`: `average` → `weightedStatusScore` +- `result.averageScore` → `result.weightedStatusScore` +- `result.averageWeightedSum` → `result.weightedStatusSum` +- `result.averageMaxPossible` → `result.weightedStatusMaxPossible` diff --git a/workspaces/scorecard/README.md b/workspaces/scorecard/README.md index 612a39207e..daa6518dc9 100644 --- a/workspaces/scorecard/README.md +++ b/workspaces/scorecard/README.md @@ -15,10 +15,10 @@ yarn install ## Documentation -| Topic | Location | -| ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -| Aggregation KPIs (`statusGrouped`, `average`), API, ownership | [plugins/scorecard-backend/docs/aggregation.md](plugins/scorecard-backend/docs/aggregation.md) | -| Backend installation and RBAC, **`scorecard.aggregationKPIs`** examples | [plugins/scorecard-backend/README.md](plugins/scorecard-backend/README.md) | -| Drill-down (entity list for a metric) | [plugins/scorecard-backend/docs/drill-down.md](plugins/scorecard-backend/docs/drill-down.md) | -| Metric thresholds, annotations, **average KPI result colors** | [plugins/scorecard-backend/docs/thresholds.md](plugins/scorecard-backend/docs/thresholds.md) | -| Frontend (homepage cards, NFS) | [plugins/scorecard/README.md](plugins/scorecard/README.md) | +| Topic | Location | +| ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| Aggregation KPIs (`statusGrouped`, `weightedStatusScore`), API, ownership | [plugins/scorecard-backend/docs/aggregation.md](plugins/scorecard-backend/docs/aggregation.md) | +| Backend installation and RBAC, **`scorecard.aggregationKPIs`** examples | [plugins/scorecard-backend/README.md](plugins/scorecard-backend/README.md) | +| Drill-down (entity list for a metric) | [plugins/scorecard-backend/docs/drill-down.md](plugins/scorecard-backend/docs/drill-down.md) | +| Metric thresholds, annotations, **weightedStatusScore KPI result colors** | [plugins/scorecard-backend/docs/thresholds.md](plugins/scorecard-backend/docs/thresholds.md) | +| Frontend (homepage cards, NFS) | [plugins/scorecard/README.md](plugins/scorecard/README.md) | diff --git a/workspaces/scorecard/app-config.local.EXAMPLE.yaml b/workspaces/scorecard/app-config.local.EXAMPLE.yaml index 0d8b54811f..fb123f5d8b 100644 --- a/workspaces/scorecard/app-config.local.EXAMPLE.yaml +++ b/workspaces/scorecard/app-config.local.EXAMPLE.yaml @@ -57,8 +57,8 @@ jira: scorecard: openPrsWeightedKpi: title: GitHub Open PRs (weighted health) - type: average - description: Weighted health average for open PRs by threshold status across your entities. + type: weightedStatusScore + description: Weighted health score for open PRs by threshold status across your entities. metricId: github.open_prs options: statusScores: diff --git a/workspaces/scorecard/app-config.yaml b/workspaces/scorecard/app-config.yaml index bb58c6d9f7..326c8ec1e6 100644 --- a/workspaces/scorecard/app-config.yaml +++ b/workspaces/scorecard/app-config.yaml @@ -246,8 +246,8 @@ scorecard: metricId: github.open_prs openPrsWeightedKpi: title: GitHub Open PRs (weighted health) - type: average - description: Weighted health average for open PRs by threshold status across your entities. + type: weightedStatusScore + description: Weighted health score for open PRs by threshold status across your entities. metricId: github.open_prs options: statusScores: diff --git a/workspaces/scorecard/packages/app-legacy/e2e-tests/scorecard.test.ts b/workspaces/scorecard/packages/app-legacy/e2e-tests/scorecard.test.ts index 0539acdd02..a76c5fb164 100644 --- a/workspaces/scorecard/packages/app-legacy/e2e-tests/scorecard.test.ts +++ b/workspaces/scorecard/packages/app-legacy/e2e-tests/scorecard.test.ts @@ -59,7 +59,7 @@ import { getTranslations, getEntityCount, getStatusGroupedCardSnapshot, - getAverageCardSnapshot, + getWeightedStatusScoreCardSnapshot, getTableFooterSnapshot, getEntitiesTableFooterRowsLabel, } from './utils/translationUtils'; @@ -73,10 +73,10 @@ import { setupHomepageAllCardsNoData, } from './utils/homepageWidgetUtils'; import { - expectAverageCardCenterPercent, - verifyAverageDonutCenterTooltip, - verifyAverageCenterTooltipBreakdownRows, -} from './utils/averageCardAssertions'; + expectWeightedStatusScoreCardCenterPercent, + verifyWeightedStatusScoreDonutCenterTooltip, + verifyWeightedStatusScoreCenterTooltipBreakdownRows, +} from './utils/weightedStatusScoreCardAssertions'; import { runAccessibilityTests } from './utils/accessibility'; import { ScorecardRoutes } from './constants/routes'; import { @@ -714,7 +714,7 @@ test.describe('Scorecard Plugin Tests', () => { }); }); - test.describe('Configured aggregation KPI - "average" type', () => { + test.describe('Configured aggregation KPI - "weightedStatusScore" type', () => { const aggregationMetadata = AGGREGATED_CARDS_METADATA.githubOpenPrsWeightedKpi; @@ -726,7 +726,7 @@ test.describe('Scorecard Plugin Tests', () => { }); }); - test.describe('Validate "average" type card content', () => { + test.describe('Validate "weightedStatusScore" type card content', () => { let card: Locator; test.beforeAll(async () => { @@ -755,19 +755,19 @@ test.describe('Scorecard Plugin Tests', () => { test('Verify center score percentage', async () => { await expect(card).toBeVisible(); - await expectAverageCardCenterPercent(card, '51.5%'); + await expectWeightedStatusScoreCardCenterPercent(card, '51.5%'); }); test('Verify center tooltip', async () => { await expect(card).toBeVisible(); - await verifyAverageDonutCenterTooltip( + await verifyWeightedStatusScoreDonutCenterTooltip( page, card, translations, - openPrsWeightedAggregatedResponse.result.averageWeightedSum, - openPrsWeightedAggregatedResponse.result.averageMaxPossible, + openPrsWeightedAggregatedResponse.result.weightedStatusSum, + openPrsWeightedAggregatedResponse.result.weightedStatusMaxPossible, ); - await verifyAverageCenterTooltipBreakdownRows( + await verifyWeightedStatusScoreCenterTooltipBreakdownRows( page, card, translations, @@ -814,12 +814,12 @@ test.describe('Scorecard Plugin Tests', () => { await expect(card).toBeVisible(); await expect(card).toMatchAriaSnapshot( - getAverageCardSnapshot(translations, { + getWeightedStatusScoreCardSnapshot(translations, { drillDownMetricId: aggregationMetadata.metricId, drillDownAggregationId: aggregationMetadata.id, cardTitle: partialResponse.metadata.title, cardDescription: partialResponse.metadata.description, - averageScoreLabel: `${partialResponse.result.averageScore}%`, + weightedStatusScoreLabel: `${partialResponse.result.weightedStatusScore}%`, homepageCalculationHealth: { healthy: String(entitiesConsidered - calculationErrorCount), total: String(entitiesConsidered), diff --git a/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/apiUtils.ts b/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/apiUtils.ts index 78fc1e1edf..a5460b2b94 100644 --- a/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/apiUtils.ts +++ b/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/apiUtils.ts @@ -69,7 +69,8 @@ export function waitForAggregationResponse( const result = json?.result; return ( - result?.averageScore !== undefined || result?.total !== undefined + result?.weightedStatusScore !== undefined || + result?.total !== undefined ); } catch { return false; diff --git a/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/scorecardResponseUtils.ts b/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/scorecardResponseUtils.ts index 8dac563eca..0105e8ffd9 100644 --- a/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/scorecardResponseUtils.ts +++ b/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/scorecardResponseUtils.ts @@ -207,14 +207,14 @@ export const openIssuesKpiMetadataResponse = { export const openPrsWeightedKpiMetadataResponse = { title: 'GitHub Open PRs (weighted health)', description: - 'Weighted health average for open PRs by threshold status across your entities.', + 'Weighted health score for open PRs by threshold status across your entities.', type: 'number', history: true, - aggregationType: aggregationTypes.average, + aggregationType: aggregationTypes.weightedStatusScore, }; /** - * Average KPI: 3×100 + 5×40 + 1×15 + 1×0 = 515 weighted sum; max 100×10 entities → 51.5% score. + * WeightedStatusScore KPI: 3×100 + 5×40 + 1×15 + 1×0 = 515 weighted sum; max 100×10 entities → 51.5% score. * Includes `critical` as a non-threshold status name (no `thresholds.critical` copy). * Colors align with aggregation KPI `options.thresholds` warning band (30–79%) in app-config. */ @@ -236,9 +236,9 @@ export const openPrsWeightedAggregatedResponse = { calculationErrorCount: 0, timestamp: '2026-01-24T14:10:32.858Z', thresholds: DEFAULT_NUMBER_THRESHOLDS, - averageScore: 51.5, - averageWeightedSum: 515, - averageMaxPossible: 1000, + weightedStatusScore: 51.5, + weightedStatusSum: 515, + weightedStatusMaxPossible: 1000, aggregationChartDisplayColor: 'rgb(224, 189, 108)', }, }; @@ -259,8 +259,8 @@ export const gitHubWeightedPartiallyAggregatedResponse = { total: 8, entitiesConsidered: 6, calculationErrorCount: 2, - averageScore: 46.7, - averageWeightedSum: 466.67, + weightedStatusScore: 46.7, + weightedStatusSum: 466.67, }, }; @@ -279,9 +279,9 @@ export const emptyOpenPrsWeightedAggregatedResponse = { ], timestamp: '2026-01-24T14:10:32.858Z', thresholds: DEFAULT_NUMBER_THRESHOLDS, - averageScore: 0, - averageWeightedSum: 0, - averageMaxPossible: 0, + weightedStatusScore: 0, + weightedStatusSum: 0, + weightedStatusMaxPossible: 0, aggregationChartDisplayColor: '#6bb300', }, }; diff --git a/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/translationUtils.ts b/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/translationUtils.ts index fb994bcc37..6e6dd6d83c 100644 --- a/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/translationUtils.ts +++ b/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/translationUtils.ts @@ -193,7 +193,7 @@ export function getSomeEntitiesNotReportingTooltip( ); } -/** Flat metric-namespace string by key (e.g. averageCenterTooltipTotalLabel). */ +/** Flat metric-namespace string by key (e.g. weightedStatusScoreCenterTooltipTotalLabel). */ export function getMetricTranslation( translations: ScorecardMessages, key: string, @@ -451,8 +451,8 @@ export function getStatusGroupedCardSnapshot( `; } -/** Snapshot for average-type homepage KPI cards (donut gauge, no threshold legend). */ -export function getAverageCardSnapshot( +/** Snapshot for weightedStatusScore-type homepage KPI cards (donut gauge, no threshold legend). */ +export function getWeightedStatusScoreCardSnapshot( translations: ScorecardMessages, options: { drillDownMetricId: 'jira.open_issues' | 'github.open_prs'; @@ -460,7 +460,7 @@ export function getAverageCardSnapshot( homepageCalculationHealth?: { healthy: string; total: string }; cardTitle: string; cardDescription: string; - averageScoreLabel: string; + weightedStatusScoreLabel: string; }, ): string { const { @@ -468,7 +468,7 @@ export function getAverageCardSnapshot( drillDownAggregationId, cardTitle, cardDescription, - averageScoreLabel, + weightedStatusScoreLabel, } = options; const aggregationSegment = drillDownAggregationId ?? drillDownMetricId; const { healthy, total } = options.homepageCalculationHealth ?? { @@ -488,7 +488,7 @@ export function getAverageCardSnapshot( - button - separator - paragraph: ${cardDescription} - - application: ${averageScoreLabel} + - application: ${weightedStatusScoreLabel} `; } diff --git a/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/averageCardAssertions.ts b/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/weightedStatusScoreCardAssertions.ts similarity index 69% rename from workspaces/scorecard/packages/app-legacy/e2e-tests/utils/averageCardAssertions.ts rename to workspaces/scorecard/packages/app-legacy/e2e-tests/utils/weightedStatusScoreCardAssertions.ts index 4782f77d80..2590f04ac3 100644 --- a/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/averageCardAssertions.ts +++ b/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/weightedStatusScoreCardAssertions.ts @@ -28,22 +28,22 @@ function interpolate(template: string, vars: Record): string { ); } -function averageCenterTooltipBreakdownTemplateKey( +function weightedStatusScoreCenterTooltipBreakdownTemplateKey( locale: string, count: number, ): - | 'averageCenterTooltipBreakdownRow_one' - | 'averageCenterTooltipBreakdownRow_other' { + | 'weightedStatusScoreCenterTooltipBreakdownRow_one' + | 'weightedStatusScoreCenterTooltipBreakdownRow_other' { if (Number.isNaN(count)) { - return 'averageCenterTooltipBreakdownRow_other'; + return 'weightedStatusScoreCenterTooltipBreakdownRow_other'; } const category = new Intl.PluralRules(locale).select(count); return category === 'one' - ? 'averageCenterTooltipBreakdownRow_one' - : 'averageCenterTooltipBreakdownRow_other'; + ? 'weightedStatusScoreCenterTooltipBreakdownRow_one' + : 'weightedStatusScoreCenterTooltipBreakdownRow_other'; } -function expectedAverageCenterTooltipBreakdownLine( +function expectedWeightedStatusScoreCenterTooltipBreakdownLine( translations: ScorecardMessages, locale: string, statusKey: string, @@ -51,7 +51,10 @@ function expectedAverageCenterTooltipBreakdownLine( score: string, ): string { const n = Number.parseInt(count, 10); - const templateKey = averageCenterTooltipBreakdownTemplateKey(locale, n); + const templateKey = weightedStatusScoreCenterTooltipBreakdownTemplateKey( + locale, + n, + ); const template = getMetricTranslation(translations, templateKey); const status = statusKey in translations.thresholds @@ -60,32 +63,40 @@ function expectedAverageCenterTooltipBreakdownLine( return interpolate(template, { status, count, score }); } -export async function expectAverageCardCenterPercent( +export async function expectWeightedStatusScoreCardCenterPercent( card: Locator, percentLabel: string, ): Promise { - await expect(card.getByTestId('average-card-center-percent')).toHaveText( - percentLabel, - ); + await expect( + card.getByTestId('weighted-status-score-card-center-percent'), + ).toHaveText(percentLabel); } -export async function verifyAverageDonutCenterTooltip( +export async function verifyWeightedStatusScoreDonutCenterTooltip( page: Page, card: Locator, translations: ScorecardMessages, weightedSum: number, maxPossible: number, ): Promise { - await card.getByTestId('average-card-center-percent-hit-area').hover(); + await card + .getByTestId('weighted-status-score-card-center-percent-hit-area') + .hover(); await expect( page.getByText( - getMetricTranslation(translations, 'averageCenterTooltipTotalLabel'), + getMetricTranslation( + translations, + 'weightedStatusScoreCenterTooltipTotalLabel', + ), { exact: true }, ), ).toBeVisible(); await expect( page.getByText( - getMetricTranslation(translations, 'averageCenterTooltipMaxLabel'), + getMetricTranslation( + translations, + 'weightedStatusScoreCenterTooltipMaxLabel', + ), { exact: true }, ), ).toBeVisible(); @@ -112,15 +123,17 @@ const OPEN_PRS_WEIGHTED_MOCK_BREAKDOWN: Array<{ /** * Per-status lines under total/max in the center donut tooltip (replaces old side-legend tooltips). */ -export async function verifyAverageCenterTooltipBreakdownRows( +export async function verifyWeightedStatusScoreCenterTooltipBreakdownRows( page: Page, card: Locator, translations: ScorecardMessages, locale: string, ): Promise { - await card.getByTestId('average-card-center-percent-hit-area').hover(); + await card + .getByTestId('weighted-status-score-card-center-percent-hit-area') + .hover(); for (const row of OPEN_PRS_WEIGHTED_MOCK_BREAKDOWN) { - const line = expectedAverageCenterTooltipBreakdownLine( + const line = expectedWeightedStatusScoreCenterTooltipBreakdownLine( translations, locale, row.statusKey, diff --git a/workspaces/scorecard/plugins/scorecard-backend/README.md b/workspaces/scorecard/plugins/scorecard-backend/README.md index a3c59e69c7..caa9114bba 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/README.md +++ b/workspaces/scorecard/plugins/scorecard-backend/README.md @@ -116,9 +116,9 @@ Thresholds define conditions to assign metric values to specific visual categori - **App Configuration**: Override defaults through `app-config.yaml` - **Entity Annotations**: Override specific thresholds per entity using catalog annotations -Thresholds are evaluated in order, and the first matching rule determines the category. The plugin supports various operators for number metrics (`>`, `>=`, `<`, `<=`, `==`, `!=`, `-` (range)) and boolean metrics (`==`, `!=`). For **number** metrics, configurations loaded through validated paths must cover the **entire real line** when two or more rules are defined (no gaps between intervals); **`average`** KPI **`options.thresholds`** follow the same rule. +Thresholds are evaluated in order, and the first matching rule determines the category. The plugin supports various operators for number metrics (`>`, `>=`, `<`, `<=`, `==`, `!=`, `-` (range)) and boolean metrics (`==`, `!=`). For **number** metrics, configurations loaded through validated paths must cover the **entire real line** when two or more rules are defined (no gaps between intervals); **`weightedStatusScore`** KPI **`options.thresholds`** follow the same rule. -For comprehensive threshold configuration guide, examples, best practices, interval validation, and **aggregation KPI result thresholds** for **`type: average`**, see [thresholds.md](./docs/thresholds.md). +For comprehensive threshold configuration guide, examples, best practices, interval validation, and **aggregation KPI result thresholds** for **`type: weightedStatusScore`**, see [thresholds.md](./docs/thresholds.md). ## Aggregation KPIs (homepage and `GET /aggregations`) @@ -140,14 +140,14 @@ scorecard: openPrsWeightedKpi: title: 'GitHub open PRs (weighted health)' description: 'Weighted health from status counts using configurable scores.' - type: average + type: weightedStatusScore metricId: github.open_prs options: statusScores: success: 100 warning: 50 error: 0 - # Optional: colors for the average-score donut (expressions apply to percentage 0–100) + # Optional: colors for the weightedStatusScore donut (expressions apply to percentage 0–100) thresholds: rules: - key: success @@ -161,17 +161,17 @@ scorecard: color: error.main ``` -| Field | Description | -| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `title` | Display title for this aggregation (returned in API metadata). | -| `description` | Display description for this aggregation. | -| `type` | Aggregation algorithm: `statusGrouped` (counts per threshold status) or `average` (normalized weighted score). | -| `metricId` | Metric provider id used to load thresholds and compute counts. | -| `options` | Optional for `statusGrouped`. **Required** for `average`: must include **`options.statusScores`** — map status keys to numeric weights (typically one entry per **metric threshold rule key**). Optionally **`options.thresholds`** (same shape as metric thresholds; see [thresholds.md — Aggregation KPI result thresholds](./docs/thresholds.md#4-aggregation-kpi-result-thresholds-average-type)); evaluated on **`averageScore`** (**0–100** portfolio percentage, **one decimal**); first match sets **`aggregationChartDisplayColor`**. The API includes **`averageScore`**, **`averageWeightedSum`**, **`averageMaxPossible`**, and **`aggregationChartDisplayColor`** (from configured or default result thresholds). | +| Field | Description | +| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `title` | Display title for this aggregation (returned in API metadata). | +| `description` | Display description for this aggregation. | +| `type` | Aggregation algorithm: `statusGrouped` (counts per threshold status) or `weightedStatusScore` (normalized weighted score). | +| `metricId` | Metric provider id used to load thresholds and compute counts. | +| `options` | Optional for `statusGrouped`. **Required** for `weightedStatusScore`: must include **`options.statusScores`** — map status keys to numeric weights (typically one entry per **metric threshold rule key**). Optionally **`options.thresholds`** (same shape as metric thresholds; see [thresholds.md — Aggregation KPI result thresholds](./docs/thresholds.md#4-aggregation-kpi-result-thresholds-weighted-status-score-type)); evaluated on **`weightedStatusScore`** (**0–100** portfolio percentage, **one decimal**); first match sets **`aggregationChartDisplayColor`**. The API includes **`weightedStatusScore`**, **`weightedStatusSum`**, **`weightedStatusMaxPossible`**, and **`aggregationChartDisplayColor`** (from configured or default result thresholds). | - **Path**: `scorecard.aggregationKPIs.`. - If **`aggregationKPIs` is omitted** or a given id is not listed, **`GET /aggregations/:aggregationId`** still works when **`aggregationId` equals the metric id** (e.g. `github.open_prs`): the backend uses that metric with the default `statusGrouped` aggregation and metric-defined title/description. -- **Startup validation**: the backend validates every **`scorecard.aggregationKPIs`** entry when the plugin loads. Invalid configuration (including **`average`** KPIs without **`options.statusScores`**, bad expressions, or unregistered **`metricId`**) causes the backend to **fail to start** with a clear error. At runtime, some edge cases may still be logged (for example skipping a KPI with unusable weights); prefer correcting app-config. See [aggregation.md](./docs/aggregation.md#configuration-validation). +- **Startup validation**: the backend validates every **`scorecard.aggregationKPIs`** entry when the plugin loads. Invalid configuration (including **`weightedStatusScore`** KPIs without **`options.statusScores`**, bad expressions, or unregistered **`metricId`**) causes the backend to **fail to start** with a clear error. At runtime, some edge cases may still be logged (for example skipping a KPI with unusable weights); prefer correcting app-config. See [aggregation.md](./docs/aggregation.md#configuration-validation). **Homepage cards** are configured in the app (for example Dynamic Home Page mount points). They should pass **`aggregationId`** matching a key in `aggregationKPIs` or the metric id for the default case. See the [Scorecard frontend plugin README](../scorecard/README.md#homepage-scorecard-cards). diff --git a/workspaces/scorecard/plugins/scorecard-backend/config.d.ts b/workspaces/scorecard/plugins/scorecard-backend/config.d.ts index a88ff66541..723486a303 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/config.d.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/config.d.ts @@ -38,13 +38,13 @@ export interface Config { metricId: string; /** Type-specific settings */ options?: { - /** Required under `options` when `type` is `average` */ + /** Required under `options` when `type` is `weightedStatusScore` */ statusScores?: { [thresholdRuleKey: string]: number; }; /** * Optional: threshold rules for coloring the KPI headline value from the aggregation result - * (e.g. average percentage 0–100 for `average` KPIs). + * (e.g. weighted status score percentage 0–100 for `weightedStatusScore` KPIs). */ thresholds?: { rules: AggregationThresholdRule[]; diff --git a/workspaces/scorecard/plugins/scorecard-backend/docs/aggregation.md b/workspaces/scorecard/plugins/scorecard-backend/docs/aggregation.md index 945d8050d1..19ea6e074f 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/docs/aggregation.md +++ b/workspaces/scorecard/plugins/scorecard-backend/docs/aggregation.md @@ -34,26 +34,26 @@ KPIs under **`scorecard.aggregationKPIs`** declare a **`type`** that selects an **`statusGrouped`** loads each owned entity’s metric status, buckets entities by status key (success, warning, error, etc.), and returns **counts per status** summed across the portfolio. Use it when you want a breakdown of how many entities are in each state (for example a status pie chart). -**`average`** rolls up each owned entity’s metric into status keys, applies **`options.statusScores`** (weights per status key), and returns **one normalized score** as a **percentage** in \[0, 100\] (one decimal), scaled against the metric’s threshold rules. Use it when you want a single “portfolio health” number (for example a donut gauge on the homepage). +**`weightedStatusScore`** rolls up each owned entity’s metric into status keys, applies **`options.statusScores`** (weights per status key), and returns **one normalized score** as a **percentage** in \[0, 100\] (one decimal), scaled against the metric’s threshold rules. Use it when you want a single “portfolio health” number (for example a donut gauge on the homepage). -| Type | Output | Typical use | -| ------------------- | --------------------------------------------------------------------------------------------------- | ----------------------------------------------- | -| **`statusGrouped`** | Counts per status key across owned entities | “How many entities are green vs red” style pie. | -| **`average`** | **`averageScore`** in \[0, 100\] (percent, one decimal) from weighted counts via **`statusScores`** | Portfolio health gauge from one headline score. | +| Type | Output | Typical use | +| ------------------------- | ---------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | +| **`statusGrouped`** | Counts per status key across owned entities | “How many entities are green vs red” style pie. | +| **`weightedStatusScore`** | **`weightedStatusScore`** in \[0, 100\] (percent, one decimal) from weighted counts via **`statusScores`** | Portfolio health gauge from one headline score. | -For **`average`**: +For **`weightedStatusScore`**: 1. The backend loads **status-grouped counts** for the configured **`metricId`** across the same **owned entities** scope as other aggregation KPIs. 2. **Weighted sum:** For each **status key** present in the aggregated counts, the contribution is **`count × weight`**. If a key appears in the data but has **no** entry in **`statusScores`**, the backend **warns** and uses weight **0** for that key. Any key present in **`statusScores`** can contribute to the sum if it appears in the stored counts (you should align keys with your metric’s threshold rules and **`statusScores`** to avoid surprising totals). -3. **Denominator and percentage:** Let **`maxWeight`** be the maximum of **`options.statusScores[rule.key]`** over each **`rule.key`** in the metric’s **merged threshold rules** (missing map entries are treated as **0** here). **`averageMaxPossible`** = **`maxWeight × total entities`**. If **`total`** is 0 or **`averageMaxPossible`** is 0, **`averageScore`** is **0**; otherwise **`averageScore`** = **`100 × (weighted sum / averageMaxPossible)`** rounded to **one decimal place** (the API and UI use this **0–100** headline directly). The value can exceed **100** if **`statusScores`** assigns a weight above **`maxWeight`** to a status that still appears in the aggregated counts; keep **`statusScores`** aligned with your metric rules to avoid that. +3. **Denominator and percentage:** Let **`maxWeight`** be the maximum of **`options.statusScores[rule.key]`** over each **`rule.key`** in the metric’s **merged threshold rules** (missing map entries are treated as **0** here). **`weightedStatusMaxPossible`** = **`maxWeight × total entities`**. If **`total`** is 0 or **`weightedStatusMaxPossible`** is 0, **`weightedStatusScore`** is **0**; otherwise **`weightedStatusScore`** = **`100 × (weighted sum / weightedStatusMaxPossible)`** rounded to **one decimal place** (the API and UI use this **0–100** headline directly). The value can exceed **100** if **`statusScores`** assigns a weight above **`maxWeight`** to a status that still appears in the aggregated counts; keep **`statusScores`** aligned with your metric rules to avoid that. -**`options.thresholds`:** **number**-style rules (same shape as metric thresholds) evaluated against **`averageScore`** on the **0–100** scale (higher = better for typical setups). The first matching rule supplies **`result.aggregationChartDisplayColor`**. If omitted from **`scorecard.aggregationKPIs`**, it stays unset in the built KPI config; **`AverageAggregationStrategy`** then applies **`DEFAULT_AVERAGE_KPI_RESULT_THRESHOLDS`** from [`src/constants/aggregationKPIs.ts`](../src/constants/aggregationKPIs.ts) when each aggregation runs and logs **info** that the default 0–100% health scale is used: **<30%** = error, **30–79%** = warning, **≥80%** = success. Full detail: [thresholds.md — Aggregation KPI result thresholds](./thresholds.md#4-aggregation-kpi-result-thresholds-average-type) and [backend README — Aggregation KPIs](../README.md#aggregation-kpis-homepage-and-get-aggregations). +**`options.thresholds`:** **number**-style rules (same shape as metric thresholds) evaluated against **`weightedStatusScore`** on the **0–100** scale (higher = better for typical setups). The first matching rule supplies **`result.aggregationChartDisplayColor`**. If omitted from **`scorecard.aggregationKPIs`**, it stays unset in the built KPI config; **`WeightedStatusScoreAggregationStrategy`** then applies **`DEFAULT_WEIGHTED_STATUS_SCORE_KPI_RESULT_THRESHOLDS`** from [`src/constants/aggregationKPIs.ts`](../src/constants/aggregationKPIs.ts) when each aggregation runs and logs **info** that the default 0–100% health scale is used: **<30%** = error, **30–79%** = warning, **≥80%** = success. Full detail: [thresholds.md — Aggregation KPI result thresholds](./thresholds.md#4-aggregation-kpi-result-thresholds-weighted-status-score-type) and [backend README — Aggregation KPIs](../README.md#aggregation-kpis-homepage-and-get-aggregations). ## Configuration validation -**`scorecard.aggregationKPIs`** is validated when the backend plugin starts. Invalid entries (unknown **`type`**, missing **`options`** for **`average`**, empty **`statusScores`**, unknown **`metricId`**, invalid threshold expressions, etc.) cause startup to **fail with an error** so misconfiguration is caught early. Fix app-config and redeploy. +**`scorecard.aggregationKPIs`** is validated when the backend plugin starts. Invalid entries (unknown **`type`**, missing **`options`** for **`weightedStatusScore`**, empty **`statusScores`**, unknown **`metricId`**, invalid threshold expressions, etc.) cause startup to **fail with an error** so misconfiguration is caught early. Fix app-config and redeploy. -For **`type: average`**, optional **`options.thresholds`** must satisfy the same **number interval / gap** rules as metric thresholds when multiple rules apply (union must cover the full real line with no gaps). Errors mention an approximate **first uncovered region**. See [Joint coverage (number metrics)](./thresholds.md#joint-coverage-number-metrics). +For **`type: weightedStatusScore`**, optional **`options.thresholds`** must satisfy the same **number interval / gap** rules as metric thresholds when multiple rules apply (union must cover the full real line with no gaps). Errors mention an approximate **first uncovered region**. See [Joint coverage (number metrics)](./thresholds.md#joint-coverage-number-metrics). Schema reference for config discovery (IDE / `backstage-cli config:schema`): see **`config.d.ts`** on the backend package (`aggregationKPIs` and nested **`options`**). @@ -63,10 +63,10 @@ Schema reference for config discovery (IDE / `backstage-cli config:schema`): see Use this endpoint for all new integrations. -- **`aggregationId`** may be a key under **`scorecard.aggregationKPIs`** in app-config (see the [backend README](../README.md#aggregation-kpis-homepage-and-get-aggregations)), which supplies **title**, **description**, **type**, **metricId**, and for **`type: average`** the **`options.statusScores`** map (threshold rule key → weight), with room for more **`options`** fields per type later. -- If there is **no** `scorecard.aggregationKPIs.` block, the backend still responds successfully: it treats **`aggregationId` as the `metricId`** and uses the default **statusGrouped** strategy (same as calling **`/aggregations/`** with a metric id). A **warning** is logged on the server so missing KPI config is visible in operator logs. To get a custom **title**, **`average`** type, or other KPI options, you must add that block; a typo in the id falls through to this default and can look like “wrong” aggregation behavior in the UI, so check logs and app-config. +- **`aggregationId`** may be a key under **`scorecard.aggregationKPIs`** in app-config (see the [backend README](../README.md#aggregation-kpis-homepage-and-get-aggregations)), which supplies **title**, **description**, **type**, **metricId**, and for **`type: weightedStatusScore`** the **`options.statusScores`** map (threshold rule key → weight), with room for more **`options`** fields per type later. +- If there is **no** `scorecard.aggregationKPIs.` block, the backend still responds successfully: it treats **`aggregationId` as the `metricId`** and uses the default **statusGrouped** strategy (same as calling **`/aggregations/`** with a metric id). A **warning** is logged on the server so missing KPI config is visible in operator logs. To get a custom **title**, **`weightedStatusScore`** type, or other KPI options, you must add that block; a typo in the id falls through to this default and can look like “wrong” aggregation behavior in the UI, so check logs and app-config. -The response shape includes **`id`**, **`status`**, **`metadata`** (title, description, type, aggregation type), and **`result`** (counts per threshold rule, total, thresholds). The **`result`** object also includes **`entitiesConsidered`** (count of in-scope owned entities that have **at least one** latest `metric_values` row for this metric) and **`calculationErrorCount`** (how many of those latest rows are metric calculation failures: `error_message` set and `value` null), so the homepage ratio matches the population behind the drill-down table rather than the raw number of owned catalog refs. For **`average`**, **`result`** also includes **`averageScore`** (portfolio percentage in \[0, 100\], one decimal), **`averageWeightedSum`**, and **`averageMaxPossible`** (see backend README). The homepage card shows a donut gauge for this type instead of a multi-slice status pie. +The response shape includes **`id`**, **`status`**, **`metadata`** (title, description, type, aggregation type), and **`result`** (counts per threshold rule, total, thresholds). The **`result`** object also includes **`entitiesConsidered`** (count of in-scope owned entities that have **at least one** latest `metric_values` row for this metric) and **`calculationErrorCount`** (how many of those latest rows are metric calculation failures: `error_message` set and `value` null), so the homepage ratio matches the population behind the drill-down table rather than the raw number of owned catalog refs. For **`weightedStatusScore`**, **`result`** also includes **`weightedStatusScore`** (portfolio percentage in \[0, 100\], one decimal), **`weightedStatusSum`**, and **`weightedStatusMaxPossible`** (see backend README). The homepage card shows a donut gauge for this type instead of a multi-slice status pie. **“Without calculation errors” on the homepage:** `healthy = entitiesConsidered - calculationErrorCount` counts only among entities that already have a latest stored row for this metric. Owned entities with **no** row yet are omitted from **`entitiesConsidered`** (same as omitting them from the drill-down list until data exists). @@ -90,7 +90,7 @@ When the user owns no relevant entities, the API returns an aggregation with **z ### Drill-down vs aggregation id -The aggregation API uses **`aggregationId`** (KPI key or metric id). **Entity drill-down** remains **metric-scoped**: use **`GET /metrics/:metricId/catalog/aggregations/entities`** with the KPI’s **`metricId`**, not the KPI key. That applies to both **`statusGrouped`** and **`average`** KPIs. See [drill-down.md](./drill-down.md). +The aggregation API uses **`aggregationId`** (KPI key or metric id). **Entity drill-down** remains **metric-scoped**: use **`GET /metrics/:metricId/catalog/aggregations/entities`** with the KPI’s **`metricId`**, not the KPI key. That applies to both **`statusGrouped`** and **`weightedStatusScore`** KPIs. See [drill-down.md](./drill-down.md). ### **Deprecated API:** `GET /metrics/:metricId/catalog/aggregations` @@ -175,6 +175,6 @@ If the user doesn't have access to the specified metric: 5. **Metric access**: Aggregation routes enforce **`scorecard.metric.read`** for the underlying metric and **`catalog.entity.read`** for each included entity; expect **`403 Forbidden`** when either check fails. -For RBAC, scheduling, full endpoint reference, and **app-config examples** for **`average`** KPIs (including **`thresholds`**), see the [Scorecard backend README](../README.md). +For RBAC, scheduling, full endpoint reference, and **app-config examples** for **`weightedStatusScore`** KPIs (including **`thresholds`**), see the [Scorecard backend README](../README.md). -For **per-entity threshold overrides** (annotations), **average KPI result thresholds**, and expression reference, see [thresholds.md](./thresholds.md). +For **per-entity threshold overrides** (annotations), **weightedStatusScore KPI result thresholds**, and expression reference, see [thresholds.md](./thresholds.md). diff --git a/workspaces/scorecard/plugins/scorecard-backend/docs/drill-down.md b/workspaces/scorecard/plugins/scorecard-backend/docs/drill-down.md index d7d9fc1433..8fdb76d93c 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/docs/drill-down.md +++ b/workspaces/scorecard/plugins/scorecard-backend/docs/drill-down.md @@ -6,7 +6,7 @@ The Scorecard plugin provides a drill-down endpoint that returns detailed entity High-level aggregation for homepage KPIs uses **`GET /aggregations/:aggregationId`** (see [aggregation.md](./aggregation.md)). Drill-down is **metric-scoped**: the endpoint **`/metrics/:metricId/catalog/aggregations/entities`** lists entities and values for a single **metric id** (not a KPI id). -**Note:** If the homepage card uses a KPI key (for example **`openPrsWeightedKpi`**) with **`type: average`**, drill-down still uses the KPI’s configured **`metricId`** (e.g. **`github.open_prs`**) in this path—not the KPI id. +**Note:** If the homepage card uses a KPI key (for example **`openPrsWeightedKpi`**) with **`type: weightedStatusScore`**, drill-down still uses the KPI’s configured **`metricId`** (e.g. **`github.open_prs`**) in this path—not the KPI id. The drill-down endpoint provides a detailed view of entities and their metric values. It allows managers and platform engineers to: diff --git a/workspaces/scorecard/plugins/scorecard-backend/docs/thresholds.md b/workspaces/scorecard/plugins/scorecard-backend/docs/thresholds.md index fb93aac7a6..fa970a6a02 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/docs/thresholds.md +++ b/workspaces/scorecard/plugins/scorecard-backend/docs/thresholds.md @@ -152,19 +152,19 @@ For **number** metrics, each overridden expression is validated in isolation fir **Counterexample:** Provider rules partition the line (`'<10'`, `'10-20'`, `'>20'`). Overriding only warning to `'11-20'` leaves **`10`** and **`(10, 11)`** uncovered on the merged set—fix the override or adjacent rules so the union again covers **(-∞, +∞)**. -### 4. Aggregation KPI result thresholds (`average` type) +### 4. Aggregation KPI result thresholds (`weightedStatusScore` type) -These thresholds are **not** per-entity metric rules. They apply only to homepage aggregation KPIs where **`scorecard.aggregationKPIs..type`** is **`average`**. +These thresholds are **not** per-entity metric rules. They apply only to homepage aggregation KPIs where **`scorecard.aggregationKPIs..type`** is **`weightedStatusScore`**. **Configuration path:** `scorecard.aggregationKPIs..options.thresholds` -**YAML shape:** Same as metric thresholds — a **`rules`** array of **`key`**, **`expression`**, and optional **`color`** (and optional **`icon`**, though icons are not used for the average KPI donut). Expressions are **number**-style and are evaluated against **`averageScore`**, the backend’s portfolio **percentage** in **`[0, 100]`** (one decimal; see [Entity Aggregation](./aggregation.md)). The **first** matching rule wins; its **`color`** is returned on the API as **`result.aggregationChartDisplayColor`**. +**YAML shape:** Same as metric thresholds — a **`rules`** array of **`key`**, **`expression`**, and optional **`color`** (and optional **`icon`**, though icons are not used for the weightedStatusScore KPI donut). Expressions are **number**-style and are evaluated against **`weightedStatusScore`**, the backend’s portfolio **percentage** in **`[0, 100]`** (one decimal; see [Entity Aggregation](./aggregation.md)). The **first** matching rule wins; its **`color`** is returned on the API as **`result.aggregationChartDisplayColor`**. -**Defaults:** If **`thresholds`** is omitted from app-config under **`options`**, it is not injected at config-parse time. **`AverageAggregationStrategy`** applies **`DEFAULT_AVERAGE_KPI_RESULT_THRESHOLDS`** from [`src/constants/aggregationKPIs.ts`](../src/constants/aggregationKPIs.ts) when serving an aggregation: **`<30`** → error, **`30-79`** → warning, **`>=80`** → success (higher percentage = better). When that default path is used, the strategy logs at **info** that the built-in 0–100% scale is in effect. +**Defaults:** If **`thresholds`** is omitted from app-config under **`options`**, it is not injected at config-parse time. **`WeightedStatusScoreAggregationStrategy`** applies **`DEFAULT_WEIGHTED_STATUS_SCORE_KPI_RESULT_THRESHOLDS`** from [`src/constants/aggregationKPIs.ts`](../src/constants/aggregationKPIs.ts) when serving an aggregation: **`<30`** → error, **`30-79`** → warning, **`>=80`** → success (higher percentage = better). When that default path is used, the strategy logs at **info** that the built-in 0–100% scale is in effect. -**Startup validation:** Invalid rules or expressions are caught when the backend plugin loads, together with the rest of **`scorecard.aggregationKPIs`**. Average KPI **`options.thresholds`** must also satisfy **joint full-line coverage** for number expressions (see [Joint coverage (number metrics)](#joint-coverage-number-metrics)), for example ensure ranges and comparison rules meet at boundaries (**`10-75`** with **`>=75`** and **`<10`**, not **`10-74`** with **`>=75`**, which would leave **`(74, 75)`** uncovered). See [aggregation.md — Configuration validation](./aggregation.md#configuration-validation). +**Startup validation:** Invalid rules or expressions are caught when the backend plugin loads, together with the rest of **`scorecard.aggregationKPIs`**. WeightedStatusScore KPI **`options.thresholds`** must also satisfy **joint full-line coverage** for number expressions (see [Joint coverage (number metrics)](#joint-coverage-number-metrics)), for example ensure ranges and comparison rules meet at boundaries (**`10-75`** with **`>=75`** and **`<10`**, not **`10-74`** with **`>=75`**, which would leave **`(74, 75)`** uncovered). See [aggregation.md — Configuration validation](./aggregation.md#configuration-validation). -**Further reading:** [Entity Aggregation](./aggregation.md) (`average` algorithm, API, drill-down); [Scorecard backend README — Aggregation KPIs](../README.md#aggregation-kpis-homepage-and-get-aggregations) (full **`aggregationKPIs`** example including **`statusScores`**). +**Further reading:** [Entity Aggregation](./aggregation.md) (`weightedStatusScore` algorithm, API, drill-down); [Scorecard backend README — Aggregation KPIs](../README.md#aggregation-kpis-homepage-and-get-aggregations) (full **`aggregationKPIs`** example including **`statusScores`**). ## Threshold Priority Order @@ -429,6 +429,6 @@ rules: ## Related documentation -- [Entity Aggregation](./aggregation.md) — ownership, **`GET /aggregations/:aggregationId`**, **`statusGrouped`** vs **`average`** +- [Entity Aggregation](./aggregation.md) — ownership, **`GET /aggregations/:aggregationId`**, **`statusGrouped`** vs **`weightedStatusScore`** - [Drill-down](./drill-down.md) — entity list for a metric (`metricId`, not KPI id) - [Scorecard backend README](../README.md) — install, RBAC, **`aggregationKPIs`** examples diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/constants/aggregationKPIs.ts b/workspaces/scorecard/plugins/scorecard-backend/src/constants/aggregationKPIs.ts index 65c9fb9eb7..1c1961a348 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/constants/aggregationKPIs.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/constants/aggregationKPIs.ts @@ -23,25 +23,26 @@ export const AGGREGATION_KPIS_CONFIG_PATH = 'scorecard.aggregationKPIs' as const; /** - * Default applied by `AverageAggregationStrategy` when `options.thresholds` is omitted + * Default applied by `WeightedStatusScoreAggregationStrategy` when `options.thresholds` is omitted * from app-config. Higher headline percentage (0–100) = better. Evaluated in order; first match wins. */ -export const DEFAULT_AVERAGE_KPI_RESULT_THRESHOLDS: ThresholdConfig = { - rules: [ - { - key: 'success', - expression: '>=80', - color: ScorecardThresholdRuleColors.SUCCESS, - }, - { - key: 'warning', - expression: '30-80', - color: ScorecardThresholdRuleColors.WARNING, - }, - { - key: 'error', - expression: '<30', - color: ScorecardThresholdRuleColors.ERROR, - }, - ], -}; +export const DEFAULT_WEIGHTED_STATUS_SCORE_KPI_RESULT_THRESHOLDS: ThresholdConfig = + { + rules: [ + { + key: 'success', + expression: '>=80', + color: ScorecardThresholdRuleColors.SUCCESS, + }, + { + key: 'warning', + expression: '30-80', + color: ScorecardThresholdRuleColors.WARNING, + }, + { + key: 'error', + expression: '<30', + color: ScorecardThresholdRuleColors.ERROR, + }, + ], + }; diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/AggregationsService.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/AggregationsService.test.ts index 38fd55c34f..a25a29b1c4 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/AggregationsService.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/AggregationsService.test.ts @@ -18,12 +18,12 @@ import { ConfigReader } from '@backstage/config'; import { mockServices } from '@backstage/backend-test-utils'; import { aggregationTypes, - type AggregatedMetricAverageResult, + type WeightedStatusScoreAggregationResult, Metric, ThresholdConfig, type AggregationConfig, } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; -import { DEFAULT_AVERAGE_KPI_RESULT_THRESHOLDS } from '../../constants/aggregationKPIs'; +import { DEFAULT_WEIGHTED_STATUS_SCORE_KPI_RESULT_THRESHOLDS } from '../../constants/aggregationKPIs'; import { AggregationsService } from './AggregationService'; import type { DatabaseMetricValues } from '../../database/DatabaseMetricValues'; import type { DbAggregatedMetric } from '../../database/types'; @@ -91,7 +91,7 @@ describe('AggregationsService', () => { ); }); - it('getAggregatedMetricByEntityRefs uses average strategy when configured', async () => { + it('getAggregatedMetricByEntityRefs uses weightedStatusScore strategy when configured', async () => { const dbRow: DbAggregatedMetric = { metric_id: metric.id, total: 3, @@ -114,13 +114,13 @@ describe('AggregationsService', () => { thresholds, aggregationConfig: { id: 'avgKpi', - title: 'Average KPI', - description: 'Average KPI description', + title: 'Weighted health KPI', + description: 'Weighted health score across statuses', metricId: metric.id, - type: aggregationTypes.average, + type: aggregationTypes.weightedStatusScore, options: { statusScores: { error: 0, warning: 50, success: 100 }, - thresholds: DEFAULT_AVERAGE_KPI_RESULT_THRESHOLDS, + thresholds: DEFAULT_WEIGHTED_STATUS_SCORE_KPI_RESULT_THRESHOLDS, }, } as AggregationConfig, } as AggregationOptions); @@ -130,12 +130,15 @@ describe('AggregationsService', () => { metric.id, ); - const aggregationResult = result.result as AggregatedMetricAverageResult; + const aggregationResult = + result.result as WeightedStatusScoreAggregationResult; - expect(result.metadata?.aggregationType).toBe(aggregationTypes.average); - expect(aggregationResult.averageScore).toBe(50); - expect(aggregationResult.averageWeightedSum).toBe(150); - expect(aggregationResult.averageMaxPossible).toBe(300); + expect(result.metadata?.aggregationType).toBe( + aggregationTypes.weightedStatusScore, + ); + expect(aggregationResult.weightedStatusScore).toBe(50); + expect(aggregationResult.weightedStatusSum).toBe(150); + expect(aggregationResult.weightedStatusMaxPossible).toBe(300); }); it('getAggregatedMetricByEntityRefs throws when aggregation type is not registered', async () => { @@ -185,7 +188,7 @@ describe('AggregationsService', () => { myKpi: { title: 'KPI title', description: 'KPI desc', - type: aggregationTypes.average, + type: aggregationTypes.weightedStatusScore, metricId: 'github.open_prs', options: { statusScores: { error: 0, warning: 50, success: 100 }, @@ -204,7 +207,7 @@ describe('AggregationsService', () => { const cfg = service.getAggregationConfig('myKpi'); expect(cfg.metricId).toBe('github.open_prs'); - expect(cfg.type).toBe(aggregationTypes.average); + expect(cfg.type).toBe(aggregationTypes.weightedStatusScore); expect(cfg.title).toBe('KPI title'); }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/AverageAggregationStrategy.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/WeightedStatusScoreAggregationStrategy.ts similarity index 76% rename from workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/AverageAggregationStrategy.ts rename to workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/WeightedStatusScoreAggregationStrategy.ts index 13524cb62e..3684a549a0 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/AverageAggregationStrategy.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/WeightedStatusScoreAggregationStrategy.ts @@ -16,13 +16,13 @@ import { type AggregatedMetric, - type AggregatedMetricAverageResult, + type WeightedStatusScoreAggregationResult, type AggregatedMetricResult, type ThresholdConfig, ThresholdRule, type AggregationConfigOptions, } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; -import { DEFAULT_AVERAGE_KPI_RESULT_THRESHOLDS } from '../../../constants/aggregationKPIs'; +import { DEFAULT_WEIGHTED_STATUS_SCORE_KPI_RESULT_THRESHOLDS } from '../../../constants/aggregationKPIs'; import { AggregatedMetricMapper } from '../../mappers'; import type { AggregatedMetricLoader } from '../AggregatedMetricLoader'; import type { AggregationOptions } from '../types'; @@ -30,7 +30,9 @@ import type { AggregationStrategy } from './types'; import { LoggerService } from '@backstage/backend-plugin-api'; import { ThresholdEvaluator } from '../../../threshold/ThresholdEvaluator'; -export class AverageAggregationStrategy implements AggregationStrategy { +export class WeightedStatusScoreAggregationStrategy + implements AggregationStrategy +{ constructor( private readonly loader: AggregatedMetricLoader, private readonly logger: LoggerService, @@ -46,19 +48,19 @@ export class AverageAggregationStrategy implements AggregationStrategy { if (!options?.statusScores) { throw new Error( - `The "scorecard.aggregationKPIs.${aggregationConfig.id}.options.statusScores" is required for average aggregation`, + `The "scorecard.aggregationKPIs.${aggregationConfig.id}.options.statusScores" is required for weightedStatusScore aggregation`, ); } if (!options.thresholds) { this.logger.info( - `The "scorecard.aggregationKPIs.${aggregationConfig.id}.options.thresholds" is not configured for average aggregation; ` + + `The "scorecard.aggregationKPIs.${aggregationConfig.id}.options.thresholds" is not configured for weightedStatusScore aggregation; ` + 'using the default 0–100% health scale (higher is better).', ); } const headlineThresholds = - options.thresholds ?? DEFAULT_AVERAGE_KPI_RESULT_THRESHOLDS; + options.thresholds ?? DEFAULT_WEIGHTED_STATUS_SCORE_KPI_RESULT_THRESHOLDS; const aggregatedMetric = await this.loader.loadStatusGroupedMetricByEntityRefs( @@ -72,21 +74,22 @@ export class AverageAggregationStrategy implements AggregationStrategy { metric.id, ); - const { averageScore, maxPossibleScore } = this.prepareScoreValues( - aggregatedMetric.total, - options.statusScores, - thresholds.rules, - weightedSum, - ); + const { weightedStatusScore, maxPossibleScore } = + this.prepareWeightedStatusScoreValues( + aggregatedMetric.total, + options.statusScores, + thresholds.rules, + weightedSum, + ); const aggregationChartDisplayColor = this.getAggregationChartDisplayColor( - averageScore, + weightedStatusScore, headlineThresholds, ); if (!aggregationChartDisplayColor) { throw new Error( - `The color for percentage '${averageScore}' metric '${metric.id}' is not configured. Check the 'scorecard.aggregationKPIs.${aggregationConfig.id}.options.thresholds' configuration.`, + `The color for percentage '${weightedStatusScore}' metric '${metric.id}' is not configured. Check the 'scorecard.aggregationKPIs.${aggregationConfig.id}.options.thresholds' configuration.`, ); } @@ -101,11 +104,11 @@ export class AverageAggregationStrategy implements AggregationStrategy { score: options.statusScores[rule.key] ?? 0, })), thresholds, - averageScore, - averageWeightedSum: weightedSum, - averageMaxPossible: maxPossibleScore, + weightedStatusScore, + weightedStatusSum: weightedSum, + weightedStatusMaxPossible: maxPossibleScore, aggregationChartDisplayColor, - } as AggregatedMetricAverageResult; + } as WeightedStatusScoreAggregationResult; return AggregatedMetricMapper.toAggregatedMetricResult( metric, @@ -125,7 +128,7 @@ export class AverageAggregationStrategy implements AggregationStrategy { if (score === undefined) { this.logger.warn( - `The status "${status}" is not in the statusScores for average aggregation of metric "${metricId}"`, + `The status "${status}" is not in the statusScores for weightedStatusScore aggregation of metric "${metricId}"`, ); } weightedSum += count * (score ?? 0); @@ -148,23 +151,23 @@ export class AverageAggregationStrategy implements AggregationStrategy { return thresholds.rules.find(r => r.key === matchedThresholdKey)?.color; } - private prepareScoreValues( + private prepareWeightedStatusScoreValues( numberOfEntities: Pick['total'], statusScores: AggregationConfigOptions['statusScores'], rules: ThresholdRule[], weightedSum: number, - ): { averageScore: number; maxPossibleScore: number } { + ): { weightedStatusScore: number; maxPossibleScore: number } { const statusScoresValues = rules.map(r => statusScores[r.key] ?? 0); const maxScore = Math.max(0, ...statusScoresValues); const maxPossibleScore = maxScore * numberOfEntities; - const averageScore = + const weightedStatusScore = numberOfEntities > 0 && maxPossibleScore > 0 ? Math.round((weightedSum / maxPossibleScore) * 1000) / 10 : 0; - return { averageScore, maxPossibleScore }; + return { weightedStatusScore, maxPossibleScore }; } } diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/registerStrategies.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/registerStrategies.test.ts index 0d6202bd9f..3766c848d6 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/registerStrategies.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/registerStrategies.test.ts @@ -18,11 +18,11 @@ import { mockServices } from '@backstage/backend-test-utils'; import { aggregationTypes } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; import { AggregatedMetricLoader } from '../AggregatedMetricLoader'; import { createAggregationStrategyRegistry } from './registerStrategies'; -import { AverageAggregationStrategy } from './AverageAggregationStrategy'; +import { WeightedStatusScoreAggregationStrategy } from './WeightedStatusScoreAggregationStrategy'; import { StatusGroupedAggregationStrategy } from './StatusGroupedAggregationStrategy'; describe('createAggregationStrategyRegistry', () => { - it('registers statusGrouped and average strategies', () => { + it('registers statusGrouped and weightedStatusScore strategies', () => { const loader = {} as AggregatedMetricLoader; const logger = mockServices.logger.mock(); @@ -31,8 +31,8 @@ describe('createAggregationStrategyRegistry', () => { expect(registry.get(aggregationTypes.statusGrouped)).toBeInstanceOf( StatusGroupedAggregationStrategy, ); - expect(registry.get(aggregationTypes.average)).toBeInstanceOf( - AverageAggregationStrategy, + expect(registry.get(aggregationTypes.weightedStatusScore)).toBeInstanceOf( + WeightedStatusScoreAggregationStrategy, ); expect(registry.size).toBe(2); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/registerStrategies.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/registerStrategies.ts index 8cb7c6db37..6172e05606 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/registerStrategies.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/registerStrategies.ts @@ -21,7 +21,7 @@ import { import type { AggregatedMetricLoader } from '../AggregatedMetricLoader'; import type { AggregationStrategy } from './types'; import { StatusGroupedAggregationStrategy } from './StatusGroupedAggregationStrategy'; -import { AverageAggregationStrategy } from './AverageAggregationStrategy'; +import { WeightedStatusScoreAggregationStrategy } from './WeightedStatusScoreAggregationStrategy'; import { LoggerService } from '@backstage/backend-plugin-api'; export function createAggregationStrategyRegistry( @@ -33,6 +33,9 @@ export function createAggregationStrategyRegistry( aggregationTypes.statusGrouped, new StatusGroupedAggregationStrategy(loader), ], - [aggregationTypes.average, new AverageAggregationStrategy(loader, logger)], + [ + aggregationTypes.weightedStatusScore, + new WeightedStatusScoreAggregationStrategy(loader, logger), + ], ]); } diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/averageAggregationStrategy.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/weightedStatusScoreAggregationStrategy.test.ts similarity index 78% rename from workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/averageAggregationStrategy.test.ts rename to workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/weightedStatusScoreAggregationStrategy.test.ts index 72629f1a47..bd77ba817d 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/averageAggregationStrategy.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/weightedStatusScoreAggregationStrategy.test.ts @@ -20,11 +20,11 @@ import { Metric, ThresholdConfig, } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; -import { DEFAULT_AVERAGE_KPI_RESULT_THRESHOLDS } from '../../../constants/aggregationKPIs'; +import { DEFAULT_WEIGHTED_STATUS_SCORE_KPI_RESULT_THRESHOLDS } from '../../../constants/aggregationKPIs'; import { AggregatedMetricLoader } from '../AggregatedMetricLoader'; -import { AverageAggregationStrategy } from './AverageAggregationStrategy'; +import { WeightedStatusScoreAggregationStrategy } from './WeightedStatusScoreAggregationStrategy'; -describe('AverageAggregationStrategy', () => { +describe('WeightedStatusScoreAggregationStrategy', () => { const metric = { id: 'github.open_prs', title: 'Open PRs', @@ -40,7 +40,7 @@ describe('AverageAggregationStrategy', () => { ], }; - it('computes weighted average fields from loader output', async () => { + it('computes weighted status score fields from loader output', async () => { const loadStatusGroupedMetricByEntityRefs = jest.fn().mockResolvedValue({ values: { error: 1, warning: 1, success: 1 }, total: 3, @@ -54,14 +54,14 @@ describe('AverageAggregationStrategy', () => { } as unknown as AggregatedMetricLoader; const logger = mockServices.logger.mock(); - const strategy = new AverageAggregationStrategy(loader, logger); + const strategy = new WeightedStatusScoreAggregationStrategy(loader, logger); const aggregationConfig = { id: 'avgKpi', metricId: metric.id, - type: aggregationTypes.average, + type: aggregationTypes.weightedStatusScore, options: { statusScores: { error: 0, warning: 50, success: 100 }, - thresholds: DEFAULT_AVERAGE_KPI_RESULT_THRESHOLDS, + thresholds: DEFAULT_WEIGHTED_STATUS_SCORE_KPI_RESULT_THRESHOLDS, }, } as const; @@ -77,9 +77,9 @@ describe('AverageAggregationStrategy', () => { total: 3, entitiesConsidered: 5, calculationErrorCount: 2, - averageWeightedSum: 150, - averageMaxPossible: 300, - averageScore: 50, + weightedStatusSum: 150, + weightedStatusMaxPossible: 300, + weightedStatusScore: 50, aggregationChartDisplayColor: 'warning.main', }), ); @@ -101,7 +101,7 @@ describe('AverageAggregationStrategy', () => { } as unknown as AggregatedMetricLoader; const logger = mockServices.logger.mock(); - const strategy = new AverageAggregationStrategy(loader, logger); + const strategy = new WeightedStatusScoreAggregationStrategy(loader, logger); const out = await strategy.aggregate({ metric, @@ -110,7 +110,7 @@ describe('AverageAggregationStrategy', () => { aggregationConfig: { id: 'avgKpi', metricId: metric.id, - type: aggregationTypes.average, + type: aggregationTypes.weightedStatusScore, options: { statusScores: { error: 0, warning: 50, success: 100 }, }, @@ -119,7 +119,7 @@ describe('AverageAggregationStrategy', () => { expect(logger.info).toHaveBeenCalledWith( expect.stringContaining( - 'options.thresholds" is not configured for average aggregation', + 'options.thresholds" is not configured for weightedStatusScore aggregation', ), ); expect(out.result).toEqual( @@ -141,7 +141,7 @@ describe('AverageAggregationStrategy', () => { } as unknown as AggregatedMetricLoader; const logger = mockServices.logger.mock(); - const strategy = new AverageAggregationStrategy(loader, logger); + const strategy = new WeightedStatusScoreAggregationStrategy(loader, logger); await expect( strategy.aggregate({ @@ -151,10 +151,12 @@ describe('AverageAggregationStrategy', () => { aggregationConfig: { id: 'avgKpi', metricId: metric.id, - type: aggregationTypes.average, + type: aggregationTypes.weightedStatusScore, } as any, }), - ).rejects.toThrow(/statusScores.*required for average aggregation/); + ).rejects.toThrow( + /statusScores.*required for weightedStatusScore aggregation/, + ); }); it('warns and ignores when loader returns a status not in the metric threshold rules', async () => { @@ -171,15 +173,15 @@ describe('AverageAggregationStrategy', () => { } as unknown as AggregatedMetricLoader; const logger = mockServices.logger.mock(); - const strategy = new AverageAggregationStrategy(loader, logger); + const strategy = new WeightedStatusScoreAggregationStrategy(loader, logger); const aggregationConfig = { id: 'avgKpi', metricId: metric.id, - type: aggregationTypes.average, + type: aggregationTypes.weightedStatusScore, options: { statusScores: { error: 0, warning: 50, success: 100 }, - thresholds: DEFAULT_AVERAGE_KPI_RESULT_THRESHOLDS, + thresholds: DEFAULT_WEIGHTED_STATUS_SCORE_KPI_RESULT_THRESHOLDS, }, } as const; @@ -195,9 +197,9 @@ describe('AverageAggregationStrategy', () => { expect.objectContaining({ entitiesConsidered: 4, calculationErrorCount: 1, - averageWeightedSum: 100, - averageMaxPossible: 300, - averageScore: 33.3, + weightedStatusSum: 100, + weightedStatusMaxPossible: 300, + weightedStatusScore: 33.3, }), ); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/mappers.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/mappers.test.ts index 5d0a6ef941..29b302d1c1 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/mappers.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/mappers.test.ts @@ -182,12 +182,12 @@ describe('AggregatedMetricMapper', () => { }); }); - it('should wrap a average-shaped result and aggregationType from config', () => { + it('should wrap a weightedStatusScore-shaped result and aggregationType from config', () => { const aggregationConfig: AggregationConfig = { id: 'avg.kpi', - type: aggregationTypes.average, - title: 'Avg KPI', - description: 'Average KPI', + type: aggregationTypes.weightedStatusScore, + title: 'Weighted Status Score KPI', + description: 'Weighted status score KPI', metricId: 'test.metric', } as AggregationConfig; const result = AggregatedMetricMapper.toAggregatedMetricResult( @@ -201,16 +201,18 @@ describe('AggregatedMetricMapper', () => { { name: 'error', count: 2, score: 0 }, ], thresholds, - averageScore: 50, - averageWeightedSum: 500, - averageMaxPossible: 1000, + weightedStatusScore: 50, + weightedStatusSum: 500, + weightedStatusMaxPossible: 1000, aggregationChartDisplayColor: 'warning.main', } as any, aggregationConfig, ); - expect(result.metadata.aggregationType).toBe(aggregationTypes.average); - expect((result.result as any).averageScore).toBe(50); + expect(result.metadata.aggregationType).toBe( + aggregationTypes.weightedStatusScore, + ); + expect((result.result as any).weightedStatusScore).toBe(50); }); }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/router.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/router.test.ts index 3c2750b8e3..da71520c64 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/router.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/router.test.ts @@ -1187,14 +1187,14 @@ describe('createRouter', () => { ); }); - it('should use KPI type average when configured', async () => { + it('should use KPI type weightedStatusScore when configured', async () => { const kpiConfig = new ConfigReader({ scorecard: { aggregationKPIs: { avgKpi: { title: 'Weighted health KPI', - description: 'Weighted average', - type: 'average', + description: 'Weighted status score', + type: 'weightedStatusScore', metricId: 'github.open_prs', options: { statusScores: { diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/utils/buildAggregationConfig.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/utils/buildAggregationConfig.test.ts index 5b977cb335..d482111a80 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/utils/buildAggregationConfig.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/utils/buildAggregationConfig.test.ts @@ -38,11 +38,11 @@ describe('buildAggregationConfig', () => { }); }); - it('maps average KPI config including statusScores', () => { + it('maps weightedStatusScore KPI config including statusScores', () => { const config = new ConfigReader({ title: 'Weighted health', - description: 'Average across statuses', - type: aggregationTypes.average, + description: 'Weighted health score across statuses', + type: aggregationTypes.weightedStatusScore, metricId: 'github.open_prs', options: { statusScores: { @@ -58,8 +58,8 @@ describe('buildAggregationConfig', () => { expect(result).toEqual({ id: 'avgKpi', title: 'Weighted health', - description: 'Average across statuses', - type: aggregationTypes.average, + description: 'Weighted health score across statuses', + type: aggregationTypes.weightedStatusScore, metricId: 'github.open_prs', options: { statusScores: { error: 0, warning: 50, success: 100 }, @@ -68,11 +68,11 @@ describe('buildAggregationConfig', () => { expect(result.options?.thresholds).toBeUndefined(); }); - it('maps optional thresholds for average KPIs', () => { + it('maps optional thresholds for weightedStatusScore KPIs', () => { const config = new ConfigReader({ title: 'Weighted health', - description: 'Average across statuses', - type: aggregationTypes.average, + description: 'Weighted health score across statuses', + type: aggregationTypes.weightedStatusScore, metricId: 'github.open_prs', options: { statusScores: { success: 100, warning: 50, error: 0 }, diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/utils/buildAggregationConfig.ts b/workspaces/scorecard/plugins/scorecard-backend/src/utils/buildAggregationConfig.ts index 1cad8142a8..ff4e09aa68 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/utils/buildAggregationConfig.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/utils/buildAggregationConfig.ts @@ -70,7 +70,7 @@ export function buildAggregationConfig( description: config.getString('description'), } as AggregationConfig; - if (aggregationConfig.type === aggregationTypes.average) { + if (aggregationConfig.type === aggregationTypes.weightedStatusScore) { aggregationConfig.options = { statusScores: buildStatusScores(config), thresholds: buildAggregationThresholdsConfig(config), diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/validation/schemas/aggregationConfigSchemas.ts b/workspaces/scorecard/plugins/scorecard-backend/src/validation/schemas/aggregationConfigSchemas.ts index 20eec7d0c3..f8761667b9 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/validation/schemas/aggregationConfigSchemas.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/validation/schemas/aggregationConfigSchemas.ts @@ -29,9 +29,9 @@ const statusGroupedAggregationConfigSchema = z.object({ type: z.literal(aggregationTypes.statusGrouped), }); -const averageAggregationConfigSchema = z.object({ +const weightedStatusScoreAggregationConfigSchema = z.object({ ...baseAggregationConfigSchema.shape, - type: z.literal(aggregationTypes.average), + type: z.literal(aggregationTypes.weightedStatusScore), options: z.strictObject({ statusScores: z .record(z.string(), z.number().finite()) @@ -54,5 +54,5 @@ const averageAggregationConfigSchema = z.object({ export const aggregationConfigSchema = z.discriminatedUnion('type', [ statusGroupedAggregationConfigSchema, - averageAggregationConfigSchema, + weightedStatusScoreAggregationConfigSchema, ]); diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/validation/validateAggregationConfig.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/validation/validateAggregationConfig.test.ts index 951c39d3a1..d6612b2883 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/validation/validateAggregationConfig.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/validation/validateAggregationConfig.test.ts @@ -122,7 +122,7 @@ describe('validateAggregationConfig', () => { ); }); - it('should not throw when average KPI has options.statusScores (app-config shape)', () => { + it('should not throw when weightedStatusScore KPI has options.statusScores (app-config shape)', () => { const registry = new MetricProvidersRegistry(); registry.register(new MockNumberProvider('github.open_prs', 'github')); @@ -131,8 +131,8 @@ describe('validateAggregationConfig', () => { aggregationKPIs: { openPrsWeightedKpi: { title: 'GitHub Open PRs (weighted health)', - type: aggregationTypes.average, - description: 'Weighted health average for open PRs.', + type: aggregationTypes.weightedStatusScore, + description: 'Weighted health score for open PRs.', metricId: 'github.open_prs', options: { statusScores: { @@ -151,7 +151,7 @@ describe('validateAggregationConfig', () => { ).not.toThrow(); }); - it('should throw when type is average but required options block is missing', () => { + it('should throw when type is weightedStatusScore but required options block is missing', () => { const registry = new MetricProvidersRegistry(); registry.register(new MockNumberProvider('github.open_prs', 'github')); @@ -160,8 +160,8 @@ describe('validateAggregationConfig', () => { aggregationKPIs: { avgKpi: { title: 'Avg KPI', - type: aggregationTypes.average, - description: 'Weighted health', + type: aggregationTypes.weightedStatusScore, + description: 'Weighted health score', metricId: 'github.open_prs', }, }, @@ -173,7 +173,7 @@ describe('validateAggregationConfig', () => { ); }); - it('should throw InputError when type is average but options.statusScores is empty', () => { + it('should throw InputError when type is weightedStatusScore but options.statusScores is empty', () => { const registry = new MetricProvidersRegistry(); registry.register(new MockNumberProvider('github.open_prs', 'github')); @@ -182,8 +182,8 @@ describe('validateAggregationConfig', () => { aggregationKPIs: { avgKpi: { title: 'Avg KPI', - type: aggregationTypes.average, - description: 'Weighted health', + type: aggregationTypes.weightedStatusScore, + description: 'Weighted health score', metricId: 'github.open_prs', options: { statusScores: {} }, }, @@ -196,7 +196,7 @@ describe('validateAggregationConfig', () => { ); }); - it('should not throw when average KPI includes optional thresholds', () => { + it('should not throw when weightedStatusScore KPI includes optional thresholds', () => { const registry = new MetricProvidersRegistry(); registry.register(new MockNumberProvider('github.open_prs', 'github')); @@ -205,8 +205,8 @@ describe('validateAggregationConfig', () => { aggregationKPIs: { avgKpi: { title: 'Avg KPI', - type: aggregationTypes.average, - description: 'Weighted health', + type: aggregationTypes.weightedStatusScore, + description: 'Weighted health score', metricId: 'github.open_prs', options: { statusScores: { success: 100, warning: 50, error: 0 }, @@ -245,8 +245,8 @@ describe('validateAggregationConfig', () => { aggregationKPIs: { avgKpi: { title: 'Avg KPI', - type: aggregationTypes.average, - description: 'Weighted health', + type: aggregationTypes.weightedStatusScore, + description: 'Weighted health score', metricId: 'github.open_prs', options: { statusScores: { success: 100, warning: 50, error: 0 }, @@ -270,7 +270,7 @@ describe('validateAggregationConfig', () => { ); }); - it('should throw when average KPI thresholds leave a gap on the number line', () => { + it('should throw when weightedStatusScore KPI thresholds leave a gap on the number line', () => { const registry = new MetricProvidersRegistry(); registry.register(new MockNumberProvider('github.open_prs', 'github')); @@ -279,8 +279,8 @@ describe('validateAggregationConfig', () => { aggregationKPIs: { avgKpi: { title: 'Avg KPI', - type: aggregationTypes.average, - description: 'Weighted health', + type: aggregationTypes.weightedStatusScore, + description: 'Weighted health score', metricId: 'github.open_prs', options: { statusScores: { success: 100, warning: 50, error: 0 }, diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/validation/validateAggregationConfig.ts b/workspaces/scorecard/plugins/scorecard-backend/src/validation/validateAggregationConfig.ts index 1aa223e987..7665228786 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/validation/validateAggregationConfig.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/validation/validateAggregationConfig.ts @@ -38,7 +38,7 @@ function parseAggregationConfig(config: unknown): AggregationConfig { } if ( - parsed.data?.type === aggregationTypes.average && + parsed.data?.type === aggregationTypes.weightedStatusScore && parsed.data.options?.thresholds ) { validateThresholdsForAggregation(parsed.data.options.thresholds, 'number'); diff --git a/workspaces/scorecard/plugins/scorecard-common/report.api.md b/workspaces/scorecard/plugins/scorecard-common/report.api.md index 213f62c1bd..c34eb8680a 100644 --- a/workspaces/scorecard/plugins/scorecard-common/report.api.md +++ b/workspaces/scorecard/plugins/scorecard-common/report.api.md @@ -14,14 +14,6 @@ export type AggregatedMetric = { calculationErrorCount: number; }; -// @public (undocumented) -export type AggregatedMetricAverageResult = StatusGroupedAggregationResult & { - averageScore: number; - averageWeightedSum: number; - averageMaxPossible: number; - aggregationChartDisplayColor: string; -}; - // @public (undocumented) export type AggregatedMetricResult = { id: string; @@ -65,7 +57,7 @@ export type AggregationMetadata = { // @public (undocumented) export type AggregationResultByType = | StatusGroupedAggregationResult - | AggregatedMetricAverageResult; + | WeightedStatusScoreAggregationResult; // @public export type AggregationThresholdRule = Pick< @@ -80,7 +72,7 @@ export type AggregationType = // @public export const aggregationTypes: Readonly<{ statusGrouped: 'statusGrouped'; - average: 'average'; + weightedStatusScore: 'weightedStatusScore'; }>; // @public @@ -219,5 +211,14 @@ export type ThresholdRule = { icon?: string; }; +// @public (undocumented) +export type WeightedStatusScoreAggregationResult = + StatusGroupedAggregationResult & { + weightedStatusScore: number; + weightedStatusSum: number; + weightedStatusMaxPossible: number; + aggregationChartDisplayColor: string; + }; + // (No @packageDocumentation comment for this package) ``` diff --git a/workspaces/scorecard/plugins/scorecard-common/src/constants/aggregations.ts b/workspaces/scorecard/plugins/scorecard-common/src/constants/aggregations.ts index 33d713619e..99be2b042b 100644 --- a/workspaces/scorecard/plugins/scorecard-common/src/constants/aggregations.ts +++ b/workspaces/scorecard/plugins/scorecard-common/src/constants/aggregations.ts @@ -15,7 +15,7 @@ */ const STATUS_GROUPED = 'statusGrouped' as const; -const AVERAGE = 'average' as const; +const WEIGHTED_STATUS_SCORE = 'weightedStatusScore' as const; /** * Supported aggregation types @@ -23,5 +23,5 @@ const AVERAGE = 'average' as const; */ export const aggregationTypes = Object.freeze({ statusGrouped: STATUS_GROUPED, - average: AVERAGE, + weightedStatusScore: WEIGHTED_STATUS_SCORE, }); diff --git a/workspaces/scorecard/plugins/scorecard-common/src/types/aggregation.ts b/workspaces/scorecard/plugins/scorecard-common/src/types/aggregation.ts index df1f26a3dc..a44efb9c11 100644 --- a/workspaces/scorecard/plugins/scorecard-common/src/types/aggregation.ts +++ b/workspaces/scorecard/plugins/scorecard-common/src/types/aggregation.ts @@ -30,7 +30,7 @@ export type AggregationType = export type AggregatedMetricValue = { count: number; name: string; - /** Present when the API includes per-status weights (e.g. average aggregation). */ + /** Present when the API includes per-status weights (e.g. weightedStatusScore aggregation). */ score?: number; }; @@ -70,19 +70,20 @@ export type StatusGroupedAggregationResult = Omit< 'values' > & { values: AggregatedMetricValue[]; thresholds: ThresholdConfig }; -export type AggregatedMetricAverageResult = StatusGroupedAggregationResult & { - averageScore: number; - averageWeightedSum: number; - averageMaxPossible: number; - aggregationChartDisplayColor: string; -}; +export type WeightedStatusScoreAggregationResult = + StatusGroupedAggregationResult & { + weightedStatusScore: number; + weightedStatusSum: number; + weightedStatusMaxPossible: number; + aggregationChartDisplayColor: string; + }; /** * @public */ export type AggregationResultByType = | StatusGroupedAggregationResult - | AggregatedMetricAverageResult; + | WeightedStatusScoreAggregationResult; /** * @public diff --git a/workspaces/scorecard/plugins/scorecard/README.md b/workspaces/scorecard/plugins/scorecard/README.md index 5356935f61..887635a0e9 100644 --- a/workspaces/scorecard/plugins/scorecard/README.md +++ b/workspaces/scorecard/plugins/scorecard/README.md @@ -6,7 +6,7 @@ The plugin supports both the **legacy** Backstage frontend and the **New Fronten **Features:** - **Entity scorecard tab** — View scorecard metrics on catalog entity pages (components, websites, etc.). -- **Scorecard homepage card** — Show aggregated KPIs on the home page (e.g. GitHub open PRs, Jira open issues). Supports **`statusGrouped`** (multi-slice pie) and **`average`** (weighted health donut) KPI types configured under **`scorecard.aggregationKPIs`**. +- **Scorecard homepage card** — Show aggregated KPIs on the home page (e.g. GitHub open PRs, Jira open issues). Supports **`statusGrouped`** (multi-slice pie) and **`weightedStatusScore`** (weighted health donut) KPI types configured under **`scorecard.aggregationKPIs`**. - **Scorecard Entities page** — Drill down from an aggregated metric to see the list of entities contributing to that metric, with entity-level values and status, so you can identify services impacting the KPI and investigate issues. ## Getting started @@ -235,7 +235,7 @@ The following modules and extensions are available from `@red-hat-developer-hub/ - `home-page-widget:home/scorecard-github-open-prs` — Homepage widget showing GitHub open PRs. - `home-page-widget:home/scorecard-github-filecheck-license` - Homepage widget showing file check "License". - `home-page-widget:home/scorecard-github-filecheck-codeowners` - Homepage widget showing file check "Codeowners". -- `home-page-widget:home/scorecard-github-open-prs-weighted` - Homepage widget showing average GitHub open PRs. +- `home-page-widget:home/scorecard-github-open-prs-weighted` - Homepage widget showing weighted status score for GitHub open PRs. #### Legacy app @@ -343,9 +343,9 @@ The plugin exports **`ScorecardHomepageCard`** (see [`plugin.ts`](./src/plugin.t Define KPI ids and optional labels under **`scorecard.aggregationKPIs`** so each card can call **`GET /aggregations/`** with a stable id. See [Scorecard backend README — Aggregation KPIs](../scorecard-backend/README.md#aggregation-kpis-homepage-and-get-aggregations). If you omit a KPI entry, use the **metric id** as `aggregationId` (default status-grouped aggregation). -**`type: average`** KPIs require **`options.statusScores`** (weights per threshold rule key). Optionally set **`options.thresholds`** so the API returns **`aggregationChartDisplayColor`** for the headline percentage. Behavior, validation, and drill-down notes are described in [aggregation.md](../scorecard-backend/docs/aggregation.md). +**`type: weightedStatusScore`** KPIs require **`options.statusScores`** (weights per threshold rule key). Optionally set **`options.thresholds`** so the API returns **`aggregationChartDisplayColor`** for the headline percentage. Behavior, validation, and drill-down notes are described in [aggregation.md](../scorecard-backend/docs/aggregation.md). -For **`type: average`**, the homepage card shows a **centered donut** with the headline percentage. Hovering the **center** opens a tooltip with **total score**, **max possible score**, and a **per-status breakdown** (from aggregation **`result.values`**). There is **no side status legend**; **`statusGrouped`** cards use a multi-slice pie with a legend instead. +For **`type: weightedStatusScore`**, the homepage card shows a **centered donut** with the headline percentage. Hovering the **center** opens a tooltip with **total score**, **max possible score**, and a **per-status breakdown** (from aggregation **`result.values`**). There is **no side status legend**; **`statusGrouped`** cards use a multi-slice pie with a legend instead. #### Card props diff --git a/workspaces/scorecard/plugins/scorecard/__fixtures__/scorecardData.ts b/workspaces/scorecard/plugins/scorecard/__fixtures__/scorecardData.ts index f33f6da963..8643895a21 100644 --- a/workspaces/scorecard/plugins/scorecard/__fixtures__/scorecardData.ts +++ b/workspaces/scorecard/plugins/scorecard/__fixtures__/scorecardData.ts @@ -158,15 +158,15 @@ export const mockAggregatedScorecardData = { calculationErrorCount: 0, }, } as AggregatedMetricResult, - [aggregationTypes.average]: { + [aggregationTypes.weightedStatusScore]: { id: 'github.open_prs', status: 'success', metadata: { title: 'GitHub open PRs', - description: 'Weighted health average for the Generative AI API group.', + description: 'Weighted health score for the Generative AI API group.', type: 'number', history: true, - aggregationType: aggregationTypes.average, + aggregationType: aggregationTypes.weightedStatusScore, }, result: { values: [ @@ -177,9 +177,9 @@ export const mockAggregatedScorecardData = { total: 8, timestamp: '2024-01-15T10:30:00Z', thresholds: DEFAULT_NUMBER_THRESHOLDS, - averageScore: 75, - averageWeightedSum: 18, - averageMaxPossible: 24, + weightedStatusScore: 75, + weightedStatusSum: 18, + weightedStatusMaxPossible: 24, }, } as AggregatedMetricResult, }; diff --git a/workspaces/scorecard/plugins/scorecard/report-alpha.api.md b/workspaces/scorecard/plugins/scorecard/report-alpha.api.md index 297dde95a2..21f58654a9 100644 --- a/workspaces/scorecard/plugins/scorecard/report-alpha.api.md +++ b/workspaces/scorecard/plugins/scorecard/report-alpha.api.md @@ -198,13 +198,13 @@ export const scorecardTranslationRef: TranslationRef< readonly 'metric.lastUpdated': string; readonly 'metric.lastUpdatedNotAvailable': string; readonly 'metric.someEntitiesNotReportingValues': string; - readonly 'metric.averageCenterTooltipTotalLabel': string; - readonly 'metric.averageCenterTooltipMaxLabel': string; - readonly 'metric.averageCenterTooltipBreakdownRow_one': string; - readonly 'metric.averageCenterTooltipBreakdownRow_other': string; - readonly 'metric.averageLegendTooltipEntitiesEach_one': string; - readonly 'metric.averageLegendTooltipEntitiesEach_other': string; - readonly 'metric.averageLegendTooltipRowTotal': string; + readonly 'metric.weightedStatusScoreCenterTooltipTotalLabel': string; + readonly 'metric.weightedStatusScoreCenterTooltipMaxLabel': string; + readonly 'metric.weightedStatusScoreCenterTooltipBreakdownRow_one': string; + readonly 'metric.weightedStatusScoreCenterTooltipBreakdownRow_other': string; + readonly 'metric.weightedStatusScoreLegendTooltipEntitiesEach_one': string; + readonly 'metric.weightedStatusScoreLegendTooltipEntitiesEach_other': string; + readonly 'metric.weightedStatusScoreLegendTooltipRowTotal': string; readonly 'metric.drillDownCalculationFailures': string; readonly 'metric.homepageEntityHealthRatio': string; readonly 'metric.homepageEntityCalculationHealth': string; diff --git a/workspaces/scorecard/plugins/scorecard/report.api.md b/workspaces/scorecard/plugins/scorecard/report.api.md index 31909b82b4..072812e365 100644 --- a/workspaces/scorecard/plugins/scorecard/report.api.md +++ b/workspaces/scorecard/plugins/scorecard/report.api.md @@ -98,13 +98,13 @@ export const scorecardTranslationRef: TranslationRef< readonly 'metric.lastUpdated': string; readonly 'metric.lastUpdatedNotAvailable': string; readonly 'metric.someEntitiesNotReportingValues': string; - readonly 'metric.averageCenterTooltipTotalLabel': string; - readonly 'metric.averageCenterTooltipMaxLabel': string; - readonly 'metric.averageCenterTooltipBreakdownRow_one': string; - readonly 'metric.averageCenterTooltipBreakdownRow_other': string; - readonly 'metric.averageLegendTooltipEntitiesEach_one': string; - readonly 'metric.averageLegendTooltipEntitiesEach_other': string; - readonly 'metric.averageLegendTooltipRowTotal': string; + readonly 'metric.weightedStatusScoreCenterTooltipTotalLabel': string; + readonly 'metric.weightedStatusScoreCenterTooltipMaxLabel': string; + readonly 'metric.weightedStatusScoreCenterTooltipBreakdownRow_one': string; + readonly 'metric.weightedStatusScoreCenterTooltipBreakdownRow_other': string; + readonly 'metric.weightedStatusScoreLegendTooltipEntitiesEach_one': string; + readonly 'metric.weightedStatusScoreLegendTooltipEntitiesEach_other': string; + readonly 'metric.weightedStatusScoreLegendTooltipRowTotal': string; readonly 'metric.drillDownCalculationFailures': string; readonly 'metric.homepageEntityHealthRatio': string; readonly 'metric.homepageEntityCalculationHealth': string; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/AggregatedMetricCard.tsx b/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/AggregatedMetricCard.tsx index 208ad6406f..991cf84bf5 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/AggregatedMetricCard.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/AggregatedMetricCard.tsx @@ -16,14 +16,14 @@ import { aggregationTypes } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; import { StatusGroupedCardComponent } from './StatusGroupedCard/StatusGroupedCardComponent'; -import { AverageCardComponent } from './AverageCard/AverageCardComponent'; -import type { AverageCardComponentProps } from './AverageCard/types'; +import { WeightedStatusScoreCardComponent } from './WeightedStatusScoreCard/WeightedStatusScoreCardComponent'; +import type { WeightedStatusScoreCardComponentProps } from './WeightedStatusScoreCard/types'; import type { StatusGroupedCardComponentProps } from './StatusGroupedCard/types'; import { UnsupportedAggregationType } from './UnsupportedAggregationType'; export type AggregatedMetricCardProps = | StatusGroupedCardComponentProps - | AverageCardComponentProps; + | WeightedStatusScoreCardComponentProps; export const AggregatedMetricCard = (props: AggregatedMetricCardProps) => { switch (props.scorecard.metadata.aggregationType) { @@ -33,8 +33,12 @@ export const AggregatedMetricCard = (props: AggregatedMetricCardProps) => { {...(props as StatusGroupedCardComponentProps)} /> ); - case aggregationTypes.average: - return ; + case aggregationTypes.weightedStatusScore: + return ( + + ); default: return ( @@ -61,7 +61,7 @@ export const DonutChartTooltipContent = ({ variant="body2" sx={{ color: 'text.primary', fontWeight: 500 }} > - {t('metric.averageCenterTooltipBreakdownRow', { + {t('metric.weightedStatusScoreCenterTooltipBreakdownRow', { count: row.count, status: getTranslatedStatus(row.name, t), score: formatAggregationScoreDetail(row.score ?? 0), diff --git a/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/AverageCard/LegendTooltipContent.tsx b/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/WeightedStatusScoreCard/LegendTooltipContent.tsx similarity index 91% rename from workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/AverageCard/LegendTooltipContent.tsx rename to workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/WeightedStatusScoreCard/LegendTooltipContent.tsx index bc60416ad2..8ee9d7d576 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/AverageCard/LegendTooltipContent.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/WeightedStatusScoreCard/LegendTooltipContent.tsx @@ -39,13 +39,13 @@ export const LegendTooltipContent = ({ return ( { +}: WeightedStatusScoreCardComponentProps) => { const theme = useTheme(); const [centerTooltipPosition, setCenterTooltipPosition] = @@ -65,26 +68,26 @@ export const AverageCardComponent = ({ }); }; - const averageScorePercent = scorecard.result.averageScore; + const weightedStatusScorePercent = scorecard.result.weightedStatusScore; const { fill: chartFillPercent, remainder: chartRemainderPercent } = - clampPercentForDonut(averageScorePercent); + clampPercentForDonut(weightedStatusScorePercent); - const centerPercentLabel = `${formatPercentage(averageScorePercent)}%`; + const centerPercentLabel = `${formatPercentage(weightedStatusScorePercent)}%`; const arcResolvedColor = resolveStatusColor( theme, scorecard.result.aggregationChartDisplayColor, ); - const averagePieData: PieData[] = [ + const weightedStatusScorePieData: PieData[] = [ { - name: AVERAGE_SCORE_SLICE, + name: WEIGHTED_STATUS_SCORE_SLICE, value: chartFillPercent, color: arcResolvedColor, }, { - name: AVERAGE_REMAINDER_SLICE, + name: WEIGHTED_STATUS_SCORE_REMAINDER_SLICE, value: chartRemainderPercent, color: theme.palette.grey[300], }, @@ -114,9 +117,9 @@ export const AverageCardComponent = ({ > ( - } diff --git a/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/AverageCard/AverageCardPieCenterLabel.tsx b/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/WeightedStatusScoreCard/WeightedStatusScoreCardPieCenterLabel.tsx similarity index 85% rename from workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/AverageCard/AverageCardPieCenterLabel.tsx rename to workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/WeightedStatusScoreCard/WeightedStatusScoreCardPieCenterLabel.tsx index 44235d31cb..5f157112e3 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/AverageCard/AverageCardPieCenterLabel.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/WeightedStatusScoreCard/WeightedStatusScoreCardPieCenterLabel.tsx @@ -18,14 +18,14 @@ import { PieLabelRenderProps } from 'recharts'; import { TooltipPosition } from './types'; -type AverageCardPieCenterLabelProps = PieLabelRenderProps & { +type WeightedStatusScoreCardPieCenterLabelProps = PieLabelRenderProps & { centerPercentLabel: string; arcResolvedColor: string; updateCenterTooltipPosition: (e: React.MouseEvent) => void; setCenterTooltipPosition: (position: TooltipPosition | null) => void; }; -export function AverageCardPieCenterLabel({ +export function WeightedStatusScoreCardPieCenterLabel({ cx, cy, index, @@ -33,7 +33,7 @@ export function AverageCardPieCenterLabel({ arcResolvedColor, updateCenterTooltipPosition, setCenterTooltipPosition, -}: AverageCardPieCenterLabelProps) { +}: WeightedStatusScoreCardPieCenterLabelProps) { if ( cx === undefined || cx === null || @@ -55,7 +55,7 @@ export function AverageCardPieCenterLabel({ fill="transparent" stroke="none" pointerEvents="all" - data-testid="average-card-center-percent-hit-area" + data-testid="weighted-status-score-card-center-percent-hit-area" onMouseEnter={e => { updateCenterTooltipPosition(e); }} @@ -71,7 +71,7 @@ export function AverageCardPieCenterLabel({ fontSize={24} fontWeight={500} pointerEvents="none" - data-testid="average-card-center-percent" + data-testid="weighted-status-score-card-center-percent" > {centerPercentLabel} diff --git a/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/AverageCard/types.ts b/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/WeightedStatusScoreCard/types.ts similarity index 86% rename from workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/AverageCard/types.ts rename to workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/WeightedStatusScoreCard/types.ts index 48169ffe8c..a2f7b116f9 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/AverageCard/types.ts +++ b/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/WeightedStatusScoreCard/types.ts @@ -15,13 +15,13 @@ */ import { - AggregatedMetricAverageResult, + WeightedStatusScoreAggregationResult, AggregatedMetricResult, } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; -export type AverageCardComponentProps = { +export type WeightedStatusScoreCardComponentProps = { scorecard: Omit & { - result: AggregatedMetricAverageResult; + result: WeightedStatusScoreAggregationResult; }; cardTitle: string; description: string; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/components/CardLegendContent.tsx b/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/components/CardLegendContent.tsx index 4eaf833a82..681b9b059b 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/components/CardLegendContent.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/components/CardLegendContent.tsx @@ -17,7 +17,7 @@ import CustomLegend from '../../ScorecardHomepageSection/CustomLegend'; import type { PieData } from '../../types'; import type { PieLegendContentProps } from '../../ScorecardHomepageSection/ResponsivePieChart'; -import type { TooltipPosition } from '../AverageCard/types'; +import type { TooltipPosition } from '../WeightedStatusScoreCard/types'; export type CardLegendContentProps = PieLegendContentProps & { activeIndex: number | null; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/components/CardTooltip.tsx b/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/components/CardTooltip.tsx index 52144b1062..525119dcc4 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/components/CardTooltip.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/components/CardTooltip.tsx @@ -21,7 +21,7 @@ import { import Box from '@mui/material/Box'; import Portal from '@mui/material/Portal'; import type { PieData } from '../../types'; -import { TooltipPosition } from '../AverageCard/types'; +import { TooltipPosition } from '../WeightedStatusScoreCard/types'; type CardTooltipProps = { tooltipPosition: TooltipPosition; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/__tests__/CustomLegend.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/__tests__/CustomLegend.test.tsx index fd80b6b16d..d484ab119f 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/__tests__/CustomLegend.test.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/__tests__/CustomLegend.test.tsx @@ -146,16 +146,16 @@ describe('CustomLegend', () => { ); }); - it('should render two legend items for average donut segment names with translation key fallback', () => { - const averagePieData = [ - { name: 'averageScoreFill', value: 75, color: '#F0AB00' }, - { name: 'averageScoreRemainder', value: 25, color: '#e0e0e0' }, + it('should render two legend items for weightedStatusScore donut segment names with translation key fallback', () => { + const weightedStatusScorePieData = [ + { name: 'weightedStatusScoreFill', value: 75, color: '#F0AB00' }, + { name: 'weightedStatusScoreRemainder', value: 25, color: '#e0e0e0' }, ]; render(
{
, ); - expect(screen.getByText('AverageScoreFill')).toBeInTheDocument(); - expect(screen.getByText('AverageScoreRemainder')).toBeInTheDocument(); - expect(screen.getByTestId('legend-colorbox-averageScoreFill')).toHaveStyle( - 'background-color: #F0AB00', - ); + expect(screen.getByText('WeightedStatusScoreFill')).toBeInTheDocument(); + expect( + screen.getByText('WeightedStatusScoreRemainder'), + ).toBeInTheDocument(); + expect( + screen.getByTestId('legend-colorbox-weightedStatusScoreFill'), + ).toHaveStyle('background-color: #F0AB00'); }); }); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/__tests__/ScorecardHomepageCard.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/__tests__/ScorecardHomepageCard.test.tsx index cdc730ef66..4d56d0a195 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/__tests__/ScorecardHomepageCard.test.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/__tests__/ScorecardHomepageCard.test.tsx @@ -85,7 +85,7 @@ jest.mock('../ResponsivePieChart', () => ({ {data.name}: {data.value} ))} -
+
{typeof LabelContent === 'function' ? ( @@ -166,17 +166,17 @@ const mockScorecard: AggregatedMetricResult = { }, }; -const mockAverageScorecard: AggregatedMetricResult = { +const mockWeightedStatusScoreScorecard: AggregatedMetricResult = { ...mockScorecard, metadata: { ...mockScorecard.metadata, - aggregationType: aggregationTypes.average, + aggregationType: aggregationTypes.weightedStatusScore, }, result: { ...mockScorecard.result, - averageScore: 75, - averageWeightedSum: 18, - averageMaxPossible: 24, + weightedStatusScore: 75, + weightedStatusSum: 18, + weightedStatusMaxPossible: 24, aggregationChartDisplayColor: 'warning.main', }, }; @@ -422,27 +422,27 @@ describe('AggregatedMetricCard (homepage scorecard)', () => { expect(screen.getByTestId('pie-data-length')).toHaveTextContent('0'); }); - it('should render two donut slices and center percent for average aggregation', () => { + it('should render two donut slices and center percent for weightedStatusScore aggregation', () => { render( , { wrapper: TestWrapper }, ); expect(screen.getByTestId('pie-data-length')).toHaveTextContent('2'); expect( - screen.getByTestId('pie-segment-averageScoreFill'), + screen.getByTestId('pie-segment-weightedStatusScoreFill'), ).toBeInTheDocument(); expect( - screen.getByTestId('pie-segment-averageScoreRemainder'), + screen.getByTestId('pie-segment-weightedStatusScoreRemainder'), ).toBeInTheDocument(); - expect(screen.getByTestId('average-card-center-percent')).toHaveTextContent( - '75%', - ); + expect( + screen.getByTestId('weighted-status-score-card-center-percent'), + ).toHaveTextContent('75%'); }); it('should render error panel when aggregation type is not supported', () => { diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/de.ts b/workspaces/scorecard/plugins/scorecard/src/translations/de.ts index db35af36e4..e3f4b2e015 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/de.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/de.ts @@ -71,17 +71,19 @@ const scorecardTranslationDe = createTranslationMessages({ 'Diese Scorecard verwendet einen Aggregationstyp, der von dieser Version des Plugins nicht unterstützt wird.', 'errors.userNotFoundInCatalogMessage': 'Benutzerentität im Katalog nicht gefunden.', - 'metric.averageCenterTooltipMaxLabel': 'Maximal erreichbare Punktzahl', - 'metric.averageCenterTooltipTotalLabel': 'Gesamtpunktzahl', - 'metric.averageCenterTooltipBreakdownRow_one': + 'metric.weightedStatusScoreCenterTooltipMaxLabel': + 'Maximal erreichbare Punktzahl', + 'metric.weightedStatusScoreCenterTooltipTotalLabel': 'Gesamtpunktzahl', + 'metric.weightedStatusScoreCenterTooltipBreakdownRow_one': '{{status}}: {{count}} entity, score: {{score}}', - 'metric.averageCenterTooltipBreakdownRow_other': + 'metric.weightedStatusScoreCenterTooltipBreakdownRow_other': '{{status}}: {{count}} entities, score: {{score}}', - 'metric.averageLegendTooltipEntitiesEach_one': + 'metric.weightedStatusScoreLegendTooltipEntitiesEach_one': '{{count}} Entitäten, jede {{score}}', - 'metric.averageLegendTooltipEntitiesEach_other': + 'metric.weightedStatusScoreLegendTooltipEntitiesEach_other': '{{count}} Entitäten, jeweils {{score}}', - 'metric.averageLegendTooltipRowTotal': 'Gesamtpunktzahl {{total}}', + 'metric.weightedStatusScoreLegendTooltipRowTotal': + 'Gesamtpunktzahl {{total}}', 'metric.drillDownCalculationFailures': 'Bei der Berechnung dieser Kennzahl ist ein oder mehrere Fehler aufgetreten.', 'metric.filecheck.description': diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/es.ts b/workspaces/scorecard/plugins/scorecard/src/translations/es.ts index a809361ae5..7f2616028e 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/es.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/es.ts @@ -72,17 +72,19 @@ const scorecardTranslationEs = createTranslationMessages({ 'Esta tarjeta de puntuación utiliza un tipo de agregación que no es compatible con esta versión del complemento.', 'errors.userNotFoundInCatalogMessage': 'No se encontró la entidad de usuario en el catálogo.', - 'metric.averageCenterTooltipMaxLabel': 'Puntuación máxima posible', - 'metric.averageCenterTooltipTotalLabel': 'Puntuación total', - 'metric.averageCenterTooltipBreakdownRow_one': + 'metric.weightedStatusScoreCenterTooltipMaxLabel': + 'Puntuación máxima posible', + 'metric.weightedStatusScoreCenterTooltipTotalLabel': 'Puntuación total', + 'metric.weightedStatusScoreCenterTooltipBreakdownRow_one': '{{status}}: {{count}} entity, score: {{score}}', - 'metric.averageCenterTooltipBreakdownRow_other': + 'metric.weightedStatusScoreCenterTooltipBreakdownRow_other': '{{status}}: {{count}} entities, score: {{score}}', - 'metric.averageLegendTooltipEntitiesEach_one': + 'metric.weightedStatusScoreLegendTooltipEntitiesEach_one': '{{count}} entidad, cada una con {{score}}', - 'metric.averageLegendTooltipEntitiesEach_other': + 'metric.weightedStatusScoreLegendTooltipEntitiesEach_other': '{{count}} entidades, cada una con {{score}}', - 'metric.averageLegendTooltipRowTotal': 'Puntuación total {{total}}', + 'metric.weightedStatusScoreLegendTooltipRowTotal': + 'Puntuación total {{total}}', 'metric.drillDownCalculationFailures': 'No se pudieron validar una o más entidades cuando se calculó esta métrica.', 'metric.filecheck.description': diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts b/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts index 7e94ac5e8a..fe9ce24e8c 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts @@ -73,17 +73,17 @@ const scorecardTranslationFr = createTranslationMessages({ "Ce tableau de bord utilise un type d'agrégation qui n'est pas pris en charge par cette version du plugin.", 'errors.userNotFoundInCatalogMessage': 'Entité utilisateur introuvable dans le catalogue.', - 'metric.averageCenterTooltipMaxLabel': 'Score maximal possible', - 'metric.averageCenterTooltipTotalLabel': 'Score total', - 'metric.averageCenterTooltipBreakdownRow_one': + 'metric.weightedStatusScoreCenterTooltipMaxLabel': 'Score maximal possible', + 'metric.weightedStatusScoreCenterTooltipTotalLabel': 'Score total', + 'metric.weightedStatusScoreCenterTooltipBreakdownRow_one': '{{status}}: {{count}} entity, score: {{score}}', - 'metric.averageCenterTooltipBreakdownRow_other': + 'metric.weightedStatusScoreCenterTooltipBreakdownRow_other': '{{status}}: {{count}} entities, score: {{score}}', - 'metric.averageLegendTooltipEntitiesEach_one': + 'metric.weightedStatusScoreLegendTooltipEntitiesEach_one': 'Entité {{count}}, chaque {{score}}', - 'metric.averageLegendTooltipEntitiesEach_other': + 'metric.weightedStatusScoreLegendTooltipEntitiesEach_other': '{{count}} entités, chacune {{score}}', - 'metric.averageLegendTooltipRowTotal': 'Score total {{total}}', + 'metric.weightedStatusScoreLegendTooltipRowTotal': 'Score total {{total}}', 'metric.drillDownCalculationFailures': 'Une ou plusieurs entités ont rencontré une erreur lors du calcul de cette métrique.', 'metric.filecheck.description': diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/it.ts b/workspaces/scorecard/plugins/scorecard/src/translations/it.ts index 242ab68d98..6e1440bc21 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/it.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/it.ts @@ -73,17 +73,19 @@ const scorecardTranslationIt = createTranslationMessages({ 'Questa scorecard utilizza un tipo di aggregazione non supportato da questa versione del plugin.', 'errors.userNotFoundInCatalogMessage': 'Entità utente non trovata nel catalogo.', - 'metric.averageCenterTooltipMaxLabel': 'Punteggio massimo possibile', - 'metric.averageCenterTooltipTotalLabel': 'Punteggio totale', - 'metric.averageCenterTooltipBreakdownRow_one': + 'metric.weightedStatusScoreCenterTooltipMaxLabel': + 'Punteggio massimo possibile', + 'metric.weightedStatusScoreCenterTooltipTotalLabel': 'Punteggio totale', + 'metric.weightedStatusScoreCenterTooltipBreakdownRow_one': '{{status}}: {{count}} entity, score: {{score}}', - 'metric.averageCenterTooltipBreakdownRow_other': + 'metric.weightedStatusScoreCenterTooltipBreakdownRow_other': '{{status}}: {{count}} entities, score: {{score}}', - 'metric.averageLegendTooltipEntitiesEach_one': + 'metric.weightedStatusScoreLegendTooltipEntitiesEach_one': '{{count}} entità, ciascuna {{score}}', - 'metric.averageLegendTooltipEntitiesEach_other': + 'metric.weightedStatusScoreLegendTooltipEntitiesEach_other': '{{count}} entità, ciascuna {{score}}', - 'metric.averageLegendTooltipRowTotal': 'Punteggio totale {{total}}', + 'metric.weightedStatusScoreLegendTooltipRowTotal': + 'Punteggio totale {{total}}', 'metric.drillDownCalculationFailures': 'Si è verificato un errore durante il calcolo di questa metrica da parte di una o più entità.', 'metric.filecheck.description': diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/ja.ts b/workspaces/scorecard/plugins/scorecard/src/translations/ja.ts index aaf9571662..5d407e5186 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/ja.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/ja.ts @@ -72,17 +72,17 @@ const scorecardTranslationJa = createTranslationMessages({ 'このスコアカードは、このバージョンのプラグインでサポートされていない集計タイプを使用しています。', 'errors.userNotFoundInCatalogMessage': 'カタログにユーザーエンティティーが見つかりません。', - 'metric.averageCenterTooltipMaxLabel': '最高スコア', - 'metric.averageCenterTooltipTotalLabel': '合計スコア', - 'metric.averageCenterTooltipBreakdownRow_one': + 'metric.weightedStatusScoreCenterTooltipMaxLabel': '最高スコア', + 'metric.weightedStatusScoreCenterTooltipTotalLabel': '合計スコア', + 'metric.weightedStatusScoreCenterTooltipBreakdownRow_one': '{{status}}: {{count}} entity, score: {{score}}', - 'metric.averageCenterTooltipBreakdownRow_other': + 'metric.weightedStatusScoreCenterTooltipBreakdownRow_other': '{{status}}: {{count}} entities, score: {{score}}', - 'metric.averageLegendTooltipEntitiesEach_one': + 'metric.weightedStatusScoreLegendTooltipEntitiesEach_one': '{{count}} 個のエンティティー、各 {{score}}', - 'metric.averageLegendTooltipEntitiesEach_other': + 'metric.weightedStatusScoreLegendTooltipEntitiesEach_other': '{{count}} 個のエンティティー、各 {{score}}', - 'metric.averageLegendTooltipRowTotal': '合計スコア {{total}}', + 'metric.weightedStatusScoreLegendTooltipRowTotal': '合計スコア {{total}}', 'metric.drillDownCalculationFailures': 'このメトリクスの計算中に 1 つ以上のエンティティーが失敗しました。', 'metric.filecheck.description': diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/ref.ts b/workspaces/scorecard/plugins/scorecard/src/translations/ref.ts index d76080342c..5f6256cffd 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/ref.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/ref.ts @@ -148,16 +148,17 @@ export const scorecardMessages = { lastUpdatedNotAvailable: 'Last updated: Not available', someEntitiesNotReportingValues: 'Some entities are not reporting values related to this metric.', - averageCenterTooltipTotalLabel: 'Total score', - averageCenterTooltipMaxLabel: 'Max possible score', - averageCenterTooltipBreakdownRow_one: + weightedStatusScoreCenterTooltipTotalLabel: 'Total score', + weightedStatusScoreCenterTooltipMaxLabel: 'Max possible score', + weightedStatusScoreCenterTooltipBreakdownRow_one: '{{status}}: {{count}} entity, score: {{score}}', - averageCenterTooltipBreakdownRow_other: + weightedStatusScoreCenterTooltipBreakdownRow_other: '{{status}}: {{count}} entities, score: {{score}}', - averageLegendTooltipEntitiesEach_one: '{{count}} entity, each {{score}}', - averageLegendTooltipEntitiesEach_other: + weightedStatusScoreLegendTooltipEntitiesEach_one: + '{{count}} entity, each {{score}}', + weightedStatusScoreLegendTooltipEntitiesEach_other: '{{count}} entities, each {{score}}', - averageLegendTooltipRowTotal: 'Total score {{total}}', + weightedStatusScoreLegendTooltipRowTotal: 'Total score {{total}}', drillDownCalculationFailures: 'One or more entities failed while calculating this metric.', homepageEntityHealthRatio: '{{healthy}}/{{total}} entities', From 26b36128ce3560a39b577f156cc6d350051b5694 Mon Sep 17 00:00:00 2001 From: Ihor Mykhno Date: Wed, 17 Jun 2026 10:20:42 +0200 Subject: [PATCH 2/5] refactor(scorecard): enhance AggregatedMetricCard component with type safety and improved structure Signed-off-by: Ihor Mykhno --- .../AggregatedMetricCard.tsx | 65 +++++++++++-------- .../StatusGroupedCard/types.ts | 16 ++--- .../WeightedStatusScoreCard/types.ts | 21 +++--- .../components/AggregatedMetricCards/types.ts | 24 +++++++ 4 files changed, 80 insertions(+), 46 deletions(-) create mode 100644 workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/types.ts diff --git a/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/AggregatedMetricCard.tsx b/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/AggregatedMetricCard.tsx index 991cf84bf5..abd6bb2e6d 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/AggregatedMetricCard.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/AggregatedMetricCard.tsx @@ -14,39 +14,48 @@ * limitations under the License. */ -import { aggregationTypes } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import { + aggregationTypes, + AggregatedMetricResult, +} from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; import { StatusGroupedCardComponent } from './StatusGroupedCard/StatusGroupedCardComponent'; import { WeightedStatusScoreCardComponent } from './WeightedStatusScoreCard/WeightedStatusScoreCardComponent'; -import type { WeightedStatusScoreCardComponentProps } from './WeightedStatusScoreCard/types'; -import type { StatusGroupedCardComponentProps } from './StatusGroupedCard/types'; import { UnsupportedAggregationType } from './UnsupportedAggregationType'; -export type AggregatedMetricCardProps = - | StatusGroupedCardComponentProps - | WeightedStatusScoreCardComponentProps; +import { WeightedStatusScoreCardComponentProps } from './WeightedStatusScoreCard/types'; +import { StatusGroupedCardComponentProps } from './StatusGroupedCard/types'; +import { AggregatedMetricCardBaseProps } from './types'; + +type AggregatedMetricCardProps = AggregatedMetricCardBaseProps & { + scorecard: AggregatedMetricResult; +}; + +const isStatusGroupedCardProps = ( + props: AggregatedMetricCardProps, +): props is StatusGroupedCardComponentProps => + props.scorecard.metadata.aggregationType === aggregationTypes.statusGrouped; + +const isWeightedStatusScoreCardProps = ( + props: AggregatedMetricCardProps, +): props is WeightedStatusScoreCardComponentProps => + props.scorecard.metadata.aggregationType === + aggregationTypes.weightedStatusScore; export const AggregatedMetricCard = (props: AggregatedMetricCardProps) => { - switch (props.scorecard.metadata.aggregationType) { - case aggregationTypes.statusGrouped: - return ( - - ); - case aggregationTypes.weightedStatusScore: - return ( - - ); - default: - return ( - - ); + const { cardTitle, description, dataTestId, scorecard } = props; + + if (isStatusGroupedCardProps(props)) { + return ; + } + if (isWeightedStatusScoreCardProps(props)) { + return ; } + return ( + + ); }; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/StatusGroupedCard/types.ts b/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/StatusGroupedCard/types.ts index e708c02b9c..9c79280af2 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/StatusGroupedCard/types.ts +++ b/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/StatusGroupedCard/types.ts @@ -16,17 +16,17 @@ import { AggregatedMetricResult, + AggregationMetadata, + aggregationTypes, StatusGroupedAggregationResult, } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import { AggregatedMetricCardBaseProps } from '../types'; -export type StatusGroupedCardComponentProps = { - scorecard: Omit & { +export type StatusGroupedCardComponentProps = AggregatedMetricCardBaseProps & { + scorecard: Omit & { + metadata: AggregationMetadata & { + aggregationType: typeof aggregationTypes.statusGrouped; + }; result: StatusGroupedAggregationResult; }; - cardTitle: string; - description: string; - aggregationId: string; - showSubheader?: boolean; - showInfo?: boolean; - dataTestId?: string; }; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/WeightedStatusScoreCard/types.ts b/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/WeightedStatusScoreCard/types.ts index a2f7b116f9..1ad8dcdb59 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/WeightedStatusScoreCard/types.ts +++ b/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/WeightedStatusScoreCard/types.ts @@ -17,18 +17,19 @@ import { WeightedStatusScoreAggregationResult, AggregatedMetricResult, + AggregationMetadata, + aggregationTypes, } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import { AggregatedMetricCardBaseProps } from '../types'; -export type WeightedStatusScoreCardComponentProps = { - scorecard: Omit & { - result: WeightedStatusScoreAggregationResult; +export type WeightedStatusScoreCardComponentProps = + AggregatedMetricCardBaseProps & { + scorecard: Omit & { + metadata: AggregationMetadata & { + aggregationType: typeof aggregationTypes.weightedStatusScore; + }; + result: WeightedStatusScoreAggregationResult; + }; }; - cardTitle: string; - description: string; - aggregationId: string; - showSubheader?: boolean; - showInfo?: boolean; - dataTestId?: string; -}; export type TooltipPosition = { left: number; top: number }; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/types.ts b/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/types.ts new file mode 100644 index 0000000000..b8a6a210a3 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/types.ts @@ -0,0 +1,24 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type AggregatedMetricCardBaseProps = { + cardTitle: string; + description: string; + aggregationId: string; + showSubheader?: boolean; + showInfo?: boolean; + dataTestId?: string; +}; From 81aeb01677fb6408316923173248a90db19a08c8 Mon Sep 17 00:00:00 2001 From: Ihor Mykhno Date: Wed, 17 Jun 2026 10:33:35 +0200 Subject: [PATCH 3/5] refactor(scorecard): rename aggregation KPI from `avgKpi` to `weightedKpi` across tests and configurations Signed-off-by: Ihor Mykhno --- .../service/aggregations/AggregationsService.test.ts | 2 +- .../weightedStatusScoreAggregationStrategy.test.ts | 8 ++++---- .../scorecard-backend/src/service/router.test.ts | 4 ++-- .../src/utils/buildAggregationConfig.test.ts | 6 +++--- .../src/validation/validateAggregationConfig.test.ts | 10 +++++----- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/AggregationsService.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/AggregationsService.test.ts index a25a29b1c4..ca47ec11eb 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/AggregationsService.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/AggregationsService.test.ts @@ -113,7 +113,7 @@ describe('AggregationsService', () => { entityRefs: ['component:default/a'], thresholds, aggregationConfig: { - id: 'avgKpi', + id: 'weightedKpi', title: 'Weighted health KPI', description: 'Weighted health score across statuses', metricId: metric.id, diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/weightedStatusScoreAggregationStrategy.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/weightedStatusScoreAggregationStrategy.test.ts index bd77ba817d..18009ba06e 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/weightedStatusScoreAggregationStrategy.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/weightedStatusScoreAggregationStrategy.test.ts @@ -56,7 +56,7 @@ describe('WeightedStatusScoreAggregationStrategy', () => { const logger = mockServices.logger.mock(); const strategy = new WeightedStatusScoreAggregationStrategy(loader, logger); const aggregationConfig = { - id: 'avgKpi', + id: 'weightedKpi', metricId: metric.id, type: aggregationTypes.weightedStatusScore, options: { @@ -108,7 +108,7 @@ describe('WeightedStatusScoreAggregationStrategy', () => { entityRefs: ['component:default/a'], thresholds, aggregationConfig: { - id: 'avgKpi', + id: 'weightedKpi', metricId: metric.id, type: aggregationTypes.weightedStatusScore, options: { @@ -149,7 +149,7 @@ describe('WeightedStatusScoreAggregationStrategy', () => { entityRefs: ['component:default/a'], thresholds, aggregationConfig: { - id: 'avgKpi', + id: 'weightedKpi', metricId: metric.id, type: aggregationTypes.weightedStatusScore, } as any, @@ -176,7 +176,7 @@ describe('WeightedStatusScoreAggregationStrategy', () => { const strategy = new WeightedStatusScoreAggregationStrategy(loader, logger); const aggregationConfig = { - id: 'avgKpi', + id: 'weightedKpi', metricId: metric.id, type: aggregationTypes.weightedStatusScore, options: { diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/router.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/router.test.ts index da71520c64..5338c2b5d7 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/router.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/router.test.ts @@ -1191,7 +1191,7 @@ describe('createRouter', () => { const kpiConfig = new ConfigReader({ scorecard: { aggregationKPIs: { - avgKpi: { + weightedKpi: { title: 'Weighted health KPI', description: 'Weighted status score', type: 'weightedStatusScore', @@ -1245,7 +1245,7 @@ describe('createRouter', () => { kpiApp.use(router); kpiApp.use(mockErrorHandler()); - await request(kpiApp).get('/aggregations/avgKpi'); + await request(kpiApp).get('/aggregations/weightedKpi'); expect(getSpy).toHaveBeenCalledWith( ['component:default/my-service', 'component:default/my-other-service'], diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/utils/buildAggregationConfig.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/utils/buildAggregationConfig.test.ts index d482111a80..fff3fa2921 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/utils/buildAggregationConfig.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/utils/buildAggregationConfig.test.ts @@ -53,10 +53,10 @@ describe('buildAggregationConfig', () => { }, }); - const result = buildAggregationConfig('avgKpi', { config }); + const result = buildAggregationConfig('weightedKpi', { config }); expect(result).toEqual({ - id: 'avgKpi', + id: 'weightedKpi', title: 'Weighted health', description: 'Weighted health score across statuses', type: aggregationTypes.weightedStatusScore, @@ -86,7 +86,7 @@ describe('buildAggregationConfig', () => { }, }); - const result = buildAggregationConfig('avgKpi', { config }); + const result = buildAggregationConfig('weightedKpi', { config }); expect(result.options?.thresholds?.rules).toEqual([ { key: 'success', expression: '>=75', color: 'success.main' }, diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/validation/validateAggregationConfig.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/validation/validateAggregationConfig.test.ts index d6612b2883..158afa5548 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/validation/validateAggregationConfig.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/validation/validateAggregationConfig.test.ts @@ -158,7 +158,7 @@ describe('validateAggregationConfig', () => { const rootConfig = new ConfigReader({ scorecard: { aggregationKPIs: { - avgKpi: { + weightedKpi: { title: 'Avg KPI', type: aggregationTypes.weightedStatusScore, description: 'Weighted health score', @@ -180,7 +180,7 @@ describe('validateAggregationConfig', () => { const rootConfig = new ConfigReader({ scorecard: { aggregationKPIs: { - avgKpi: { + weightedKpi: { title: 'Avg KPI', type: aggregationTypes.weightedStatusScore, description: 'Weighted health score', @@ -203,7 +203,7 @@ describe('validateAggregationConfig', () => { const rootConfig = new ConfigReader({ scorecard: { aggregationKPIs: { - avgKpi: { + weightedKpi: { title: 'Avg KPI', type: aggregationTypes.weightedStatusScore, description: 'Weighted health score', @@ -243,7 +243,7 @@ describe('validateAggregationConfig', () => { const rootConfig = new ConfigReader({ scorecard: { aggregationKPIs: { - avgKpi: { + weightedKpi: { title: 'Avg KPI', type: aggregationTypes.weightedStatusScore, description: 'Weighted health score', @@ -277,7 +277,7 @@ describe('validateAggregationConfig', () => { const rootConfig = new ConfigReader({ scorecard: { aggregationKPIs: { - avgKpi: { + weightedKpi: { title: 'Avg KPI', type: aggregationTypes.weightedStatusScore, description: 'Weighted health score', From 132f77361ec562add2d6a97ee42b53482b9de7b8 Mon Sep 17 00:00:00 2001 From: Ihor Mykhno Date: Wed, 24 Jun 2026 13:52:30 +0200 Subject: [PATCH 4/5] refactor(scorecard): changeset file Signed-off-by: Ihor Mykhno --- .../scorecard/.changeset/stupid-knives-wonder.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/workspaces/scorecard/.changeset/stupid-knives-wonder.md b/workspaces/scorecard/.changeset/stupid-knives-wonder.md index 84f344415f..2a6fda19d5 100644 --- a/workspaces/scorecard/.changeset/stupid-knives-wonder.md +++ b/workspaces/scorecard/.changeset/stupid-knives-wonder.md @@ -1,12 +1,10 @@ --- -'@red-hat-developer-hub/backstage-plugin-scorecard-backend': minor -'@red-hat-developer-hub/backstage-plugin-scorecard-common': minor -'@red-hat-developer-hub/backstage-plugin-scorecard': minor +'@red-hat-developer-hub/backstage-plugin-scorecard-backend': major +'@red-hat-developer-hub/backstage-plugin-scorecard-common': major +'@red-hat-developer-hub/backstage-plugin-scorecard': major --- -Rename aggregation KPI type `average` to `weightedStatusScore`. - -**Breaking changes** +**BREAKING**: Rename aggregation KPI type `average` to `weightedStatusScore`. ### App config From 53fdb5942944e9cefaf752f4e246bb67e954fd05 Mon Sep 17 00:00:00 2001 From: Ihor Mykhno Date: Wed, 24 Jun 2026 14:07:14 +0200 Subject: [PATCH 5/5] feat(scorecard): support scalar aggregation KPI types Signed-off-by: Ihor Mykhno --- .../scorecard/.changeset/afraid-lies-punch.md | 13 + workspaces/scorecard/README.md | 14 +- workspaces/scorecard/app-config.yaml | 34 +++ .../app-legacy/e2e-tests/pages/HomePage.ts | 6 +- .../plugins/scorecard-backend/README.md | 43 ++- .../__fixtures__/mockAggregationConfig.ts | 92 ++++++ .../__fixtures__/mockDatabaseMetricValues.ts | 14 +- .../plugins/scorecard-backend/config.d.ts | 81 +++-- .../scorecard-backend/docs/aggregation.md | 55 +++- .../scorecard-backend/docs/drill-down.md | 2 +- .../scorecard-backend/docs/thresholds.md | 16 +- .../src/database/DatabaseMetricValues.test.ts | 249 ++++++++++++++++ .../src/database/DatabaseMetricValues.ts | 231 ++++++++++++--- .../scorecard-backend/src/database/types.ts | 11 + .../utils/getAggregateExpression.test.ts | 39 +++ .../database/utils/getAggregateExpression.ts | 37 +++ .../database/utils/mergeMaxTimestamp.test.ts | 34 +++ .../src/database/utils/mergeMaxTimestamp.ts | 24 ++ .../database/utils/normalizeTimestamp.test.ts | 55 ++++ .../src/database/utils/normalizeTimestamp.ts | 27 ++ .../AggregatedMetricLoader.test.ts | 92 +++++- .../aggregations/AggregatedMetricLoader.ts | 25 +- .../aggregations/AggregationService.ts | 15 +- .../aggregations/AggregationsService.test.ts | 277 +++++++++++------- .../strategies/ValueAggregationStrategy.ts | 76 +++++ .../WeightedStatusScoreAggregationStrategy.ts | 25 +- .../strategies/registerStrategies.test.ts | 29 +- .../strategies/registerStrategies.ts | 21 ++ .../statusGroupedAggregationStrategy.test.ts | 23 +- .../valueAggregationStrategy.test.ts | 235 +++++++++++++++ ...htedStatusScoreAggregationStrategy.test.ts | 48 ++- .../src/service/aggregations/types.ts | 23 +- .../utils/aggregationRuntimeConfig.test.ts | 86 ++++++ .../utils/aggregationRuntimeConfig.ts | 37 +++ .../src/service/mappers.test.ts | 65 ++-- .../scorecard-backend/src/service/mappers.ts | 47 ++- .../src/service/router.test.ts | 85 ++++++ .../src/utils/buildAggregationConfig.test.ts | 69 +++++ .../src/utils/buildAggregationConfig.ts | 20 +- .../src/utils/isScalarAggregationType.test.ts | 37 +++ .../src/utils/isScalarAggregationType.ts | 22 ++ .../schemas/aggregationConfigSchemas.ts | 66 ++++- .../validateAggregationConfig.test.ts | 145 ++++++++- .../validation/validateAggregationConfig.ts | 43 ++- .../plugins/scorecard-common/report.api.md | 34 ++- .../src/constants/aggregations.ts | 22 ++ .../scorecard-common/src/types/aggregation.ts | 32 +- .../scorecard/plugins/scorecard/README.md | 2 +- 48 files changed, 2439 insertions(+), 339 deletions(-) create mode 100644 workspaces/scorecard/.changeset/afraid-lies-punch.md create mode 100644 workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockAggregationConfig.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend/src/database/utils/getAggregateExpression.test.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend/src/database/utils/getAggregateExpression.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend/src/database/utils/mergeMaxTimestamp.test.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend/src/database/utils/mergeMaxTimestamp.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend/src/database/utils/normalizeTimestamp.test.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend/src/database/utils/normalizeTimestamp.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/ValueAggregationStrategy.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/valueAggregationStrategy.test.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/utils/aggregationRuntimeConfig.test.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/utils/aggregationRuntimeConfig.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend/src/utils/isScalarAggregationType.test.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend/src/utils/isScalarAggregationType.ts diff --git a/workspaces/scorecard/.changeset/afraid-lies-punch.md b/workspaces/scorecard/.changeset/afraid-lies-punch.md new file mode 100644 index 0000000000..9c8ac816a5 --- /dev/null +++ b/workspaces/scorecard/.changeset/afraid-lies-punch.md @@ -0,0 +1,13 @@ +--- +'@red-hat-developer-hub/backstage-plugin-scorecard-backend': minor +'@red-hat-developer-hub/backstage-plugin-scorecard-common': minor +'@red-hat-developer-hub/backstage-plugin-scorecard': minor +--- + +This update introduces new scalar aggregation KPIs in the scorecard configuration, including: + +- **`sum`**: Single numeric total of latest metric values across owned entities +- **`average`**: Mean of latest metric values across owned entities +- **`max`**: Maximum latest metric value across owned entities +- **`min`**: Minimum latest metric value across owned entities +- **`count`**: Number of entities with a non-null latest stored value diff --git a/workspaces/scorecard/README.md b/workspaces/scorecard/README.md index daa6518dc9..dc55dc824b 100644 --- a/workspaces/scorecard/README.md +++ b/workspaces/scorecard/README.md @@ -15,10 +15,10 @@ yarn install ## Documentation -| Topic | Location | -| ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -| Aggregation KPIs (`statusGrouped`, `weightedStatusScore`), API, ownership | [plugins/scorecard-backend/docs/aggregation.md](plugins/scorecard-backend/docs/aggregation.md) | -| Backend installation and RBAC, **`scorecard.aggregationKPIs`** examples | [plugins/scorecard-backend/README.md](plugins/scorecard-backend/README.md) | -| Drill-down (entity list for a metric) | [plugins/scorecard-backend/docs/drill-down.md](plugins/scorecard-backend/docs/drill-down.md) | -| Metric thresholds, annotations, **weightedStatusScore KPI result colors** | [plugins/scorecard-backend/docs/thresholds.md](plugins/scorecard-backend/docs/thresholds.md) | -| Frontend (homepage cards, NFS) | [plugins/scorecard/README.md](plugins/scorecard/README.md) | +| Topic | Location | +| --------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| Aggregation KPIs (`statusGrouped`, `weightedStatusScore`, scalar `sum`/`average`/`max`/`min`/`count`), API, ownership | [plugins/scorecard-backend/docs/aggregation.md](plugins/scorecard-backend/docs/aggregation.md) | +| Backend installation and RBAC, **`scorecard.aggregationKPIs`** examples | [plugins/scorecard-backend/README.md](plugins/scorecard-backend/README.md) | +| Drill-down (entity list for a metric) | [plugins/scorecard-backend/docs/drill-down.md](plugins/scorecard-backend/docs/drill-down.md) | +| Metric thresholds, annotations, **weightedStatusScore KPI result colors** | [plugins/scorecard-backend/docs/thresholds.md](plugins/scorecard-backend/docs/thresholds.md) | +| Frontend (homepage cards, NFS) | [plugins/scorecard/README.md](plugins/scorecard/README.md) | diff --git a/workspaces/scorecard/app-config.yaml b/workspaces/scorecard/app-config.yaml index 326c8ec1e6..f52c2488f9 100644 --- a/workspaces/scorecard/app-config.yaml +++ b/workspaces/scorecard/app-config.yaml @@ -275,6 +275,40 @@ scorecard: type: statusGrouped description: This KPI is provide information about whether the license file exists in the repository. metricId: filecheck.license + totalOpenBugs: + title: Total Open Bugs + description: Sum of open issues across owned entities + type: sum + metricId: jira.open_issues + options: + thresholds: + rules: + - key: success + expression: '>=80' + color: '#6bb300' # green + - key: warning + expression: '<80' + color: 'rgb(224, 189, 108)' # light orange + avgOpenPrs: + title: Average Open PRs + description: Mean open PR count per entity + type: average + metricId: github.open_prs + entitiesWithOpenIssues: + title: Entities with Open Issues + description: Count of entities with a stored open-issues value + type: count + metricId: jira.open_issues + maxOpenPrs: + title: Maximum Open PRs + description: Maximum open PR count per entity + type: max + metricId: github.open_prs + minOpenPrs: + title: Minimum Open PRs + description: Minimum open PR count per entity + type: min + metricId: github.open_prs plugins: jira: open_issues: diff --git a/workspaces/scorecard/packages/app-legacy/e2e-tests/pages/HomePage.ts b/workspaces/scorecard/packages/app-legacy/e2e-tests/pages/HomePage.ts index 5bf3e51bf3..03908bc8b3 100644 --- a/workspaces/scorecard/packages/app-legacy/e2e-tests/pages/HomePage.ts +++ b/workspaces/scorecard/packages/app-legacy/e2e-tests/pages/HomePage.ts @@ -63,9 +63,11 @@ export class HomePage { cardPattern = /Scorecard:\s*GitHub open PRs|ScorecardGithubHomepage/i; } else if (cardName === 'Scorecard: Jira open blocking') { cardPattern = /Scorecard:\s*Jira open blocking|ScorecardJiraHomepage/i; - } else if (cardName === AGGREGATED_CARDS_WIDGET_TITLES.openPrsWeightedKpi) { + } else if ( + cardName === AGGREGATED_CARDS_WIDGET_TITLES.gitHubOpenPrsWeightedKpi + ) { cardPattern = - /Scorecard:\s*GitHub open PRs \(weighted health\)|ScorecardOpenPrsWeightedKpi/i; + /Scorecard:\s*GitHub open PRs \(weighted health\)|ScorecardGitHubOpenPrsWeightedKpi/i; } else { cardPattern = new RegExp(escapeRegex(cardName), 'i'); } diff --git a/workspaces/scorecard/plugins/scorecard-backend/README.md b/workspaces/scorecard/plugins/scorecard-backend/README.md index caa9114bba..4d31bf8809 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/README.md +++ b/workspaces/scorecard/plugins/scorecard-backend/README.md @@ -118,7 +118,7 @@ Thresholds define conditions to assign metric values to specific visual categori Thresholds are evaluated in order, and the first matching rule determines the category. The plugin supports various operators for number metrics (`>`, `>=`, `<`, `<=`, `==`, `!=`, `-` (range)) and boolean metrics (`==`, `!=`). For **number** metrics, configurations loaded through validated paths must cover the **entire real line** when two or more rules are defined (no gaps between intervals); **`weightedStatusScore`** KPI **`options.thresholds`** follow the same rule. -For comprehensive threshold configuration guide, examples, best practices, interval validation, and **aggregation KPI result thresholds** for **`type: weightedStatusScore`**, see [thresholds.md](./docs/thresholds.md). +For comprehensive threshold configuration guide, examples, best practices, interval validation, and **aggregation KPI result thresholds** for **`weightedStatusScore`** and **scalar** KPI types, see [thresholds.md](./docs/thresholds.md). ## Aggregation KPIs (homepage and `GET /aggregations`) @@ -159,19 +159,44 @@ scorecard: - key: error expression: '<10' color: error.main + totalOpenBugs: + title: 'Total Open Bugs' + description: 'Sum of open issues across owned entities' + type: sum + metricId: jira.open_issues + avgOpenPrs: + title: 'Average Open PRs' + description: 'Mean open PR count per entity' + type: average + metricId: github.open_prs + criticalProjects: + title: 'Entities with Open Issues' + description: 'Count of entities with a stored open-issues value' + type: count + metricId: jira.open_issues + maxOpenPrs: + title: 'Maximum Open PRs' + description: 'Maximum open PR count across owned entities' + type: max + metricId: github.open_prs + minOpenPrs: + title: 'Minimum Open PRs' + description: 'Minimum open PR count across owned entities' + type: min + metricId: github.open_prs ``` -| Field | Description | -| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `title` | Display title for this aggregation (returned in API metadata). | -| `description` | Display description for this aggregation. | -| `type` | Aggregation algorithm: `statusGrouped` (counts per threshold status) or `weightedStatusScore` (normalized weighted score). | -| `metricId` | Metric provider id used to load thresholds and compute counts. | -| `options` | Optional for `statusGrouped`. **Required** for `weightedStatusScore`: must include **`options.statusScores`** — map status keys to numeric weights (typically one entry per **metric threshold rule key**). Optionally **`options.thresholds`** (same shape as metric thresholds; see [thresholds.md — Aggregation KPI result thresholds](./docs/thresholds.md#4-aggregation-kpi-result-thresholds-weighted-status-score-type)); evaluated on **`weightedStatusScore`** (**0–100** portfolio percentage, **one decimal**); first match sets **`aggregationChartDisplayColor`**. The API includes **`weightedStatusScore`**, **`weightedStatusSum`**, **`weightedStatusMaxPossible`**, and **`aggregationChartDisplayColor`** (from configured or default result thresholds). | +| Field | Description | +| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `title` | Display title for this aggregation (returned in API metadata). | +| `description` | Display description for this aggregation. | +| `type` | Aggregation algorithm: `statusGrouped` (counts per threshold status), `weightedStatusScore` (normalized weighted score), or scalar types `sum`, `average`, `max`, `min`, `count` (numeric rollup over latest metric values). | +| `metricId` | Metric provider id used to load thresholds and compute counts or values. | +| `options` | Type-specific options. For **`weightedStatusScore`**: **required** **`options.statusScores`** (status key → weight); optional **`options.thresholds`** (see [thresholds.md — Aggregation KPI result thresholds (weightedStatusScore)](./docs/thresholds.md#4-aggregation-kpi-result-thresholds-weightedstatusscore-type)). For **scalar types**: optional **`options.thresholds`** (see [thresholds.md — Aggregation KPI result thresholds (scalar types)](./docs/thresholds.md#5-aggregation-kpi-result-thresholds-scalar-types)). All scalar types require a **number** metric. Scalar responses include **`value`**, **`total`**, **`entitiesConsidered`**, **`calculationErrorCount`**, **`timestamp`**, and **`thresholds`** — see [aggregation.md — Scalar result fields](./docs/aggregation.md#scalar-result-fields). | - **Path**: `scorecard.aggregationKPIs.`. - If **`aggregationKPIs` is omitted** or a given id is not listed, **`GET /aggregations/:aggregationId`** still works when **`aggregationId` equals the metric id** (e.g. `github.open_prs`): the backend uses that metric with the default `statusGrouped` aggregation and metric-defined title/description. -- **Startup validation**: the backend validates every **`scorecard.aggregationKPIs`** entry when the plugin loads. Invalid configuration (including **`weightedStatusScore`** KPIs without **`options.statusScores`**, bad expressions, or unregistered **`metricId`**) causes the backend to **fail to start** with a clear error. At runtime, some edge cases may still be logged (for example skipping a KPI with unusable weights); prefer correcting app-config. See [aggregation.md](./docs/aggregation.md#configuration-validation). +- **Startup validation**: the backend validates every **`scorecard.aggregationKPIs`** entry when the plugin loads. Invalid configuration (including **`weightedStatusScore`** KPIs without **`options.statusScores`**, scalar types on boolean metrics, bad threshold expressions, or unregistered **`metricId`**) causes the backend to **fail to start** with a clear error. At runtime, some edge cases may still be logged (for example skipping a KPI with unusable weights); prefer correcting app-config. See [aggregation.md](./docs/aggregation.md#configuration-validation). **Homepage cards** are configured in the app (for example Dynamic Home Page mount points). They should pass **`aggregationId`** matching a key in `aggregationKPIs` or the metric id for the default case. See the [Scorecard frontend plugin README](../scorecard/README.md#homepage-scorecard-cards). diff --git a/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockAggregationConfig.ts b/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockAggregationConfig.ts new file mode 100644 index 0000000000..8765e042d5 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockAggregationConfig.ts @@ -0,0 +1,92 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + aggregationTypes, + DEFAULT_NUMBER_THRESHOLDS, +} from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import { DEFAULT_WEIGHTED_STATUS_SCORE_KPI_RESULT_THRESHOLDS } from '../src/constants/aggregationKPIs'; +import type { + ScalarAggregationConfig, + StatusGroupedAggregationConfig, + WeightedStatusScoreAggregationConfig, +} from '../src/validation/schemas/aggregationConfigSchemas'; +import type { FallbackStatusGroupedAggregationConfig } from '../src/service/aggregations/types'; + +export function mockStatusGroupedAggregationConfig( + overrides: Partial = {}, +): StatusGroupedAggregationConfig { + return { + id: 'test.metric', + title: 'Test Metric', + description: 'Test description', + metricId: 'test.metric', + type: aggregationTypes.statusGrouped, + ...overrides, + }; +} + +export function mockFallbackStatusGroupedAggregationConfig( + overrides: Partial = {}, +): FallbackStatusGroupedAggregationConfig { + return { + id: 'test.metric', + metricId: 'test.metric', + type: aggregationTypes.statusGrouped, + ...overrides, + }; +} + +export function mockWeightedStatusScoreAggregationConfig( + overrides: Partial> & { + options?: Partial; + } = {}, +): WeightedStatusScoreAggregationConfig { + const { options: optionsOverrides, ...rest } = overrides; + return { + id: 'weightedKpi', + title: 'Weighted health KPI', + description: 'Weighted health score across statuses', + metricId: 'test.metric', + type: aggregationTypes.weightedStatusScore, + options: { + statusScores: { error: 0, warning: 50, success: 100 }, + thresholds: + DEFAULT_WEIGHTED_STATUS_SCORE_KPI_RESULT_THRESHOLDS as WeightedStatusScoreAggregationConfig['options']['thresholds'], + ...optionsOverrides, + }, + ...rest, + }; +} + +export function mockScalarAggregationConfig( + type: ScalarAggregationConfig['type'] = aggregationTypes.sum, + overrides: Partial = {}, +): ScalarAggregationConfig { + return { + id: 'scalarKpi', + title: 'Scalar KPI', + description: 'Scalar aggregation KPI', + metricId: 'test.metric', + type, + options: { + thresholds: DEFAULT_NUMBER_THRESHOLDS as NonNullable< + ScalarAggregationConfig['options'] + >['thresholds'], + }, + ...overrides, + }; +} diff --git a/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockDatabaseMetricValues.ts b/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockDatabaseMetricValues.ts index 3fd61445ad..1e5af80aee 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockDatabaseMetricValues.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockDatabaseMetricValues.ts @@ -15,13 +15,18 @@ */ import { DatabaseMetricValues } from '../src/database/DatabaseMetricValues'; -import { DbMetricValue, DbAggregatedMetric } from '../src/database/types'; +import { + DbMetricValue, + DbAggregatedMetric, + DbScalarAggregatedMetric, +} from '../src/database/types'; type BuildMockDatabaseMetricValuesParams = { metricValues?: DbMetricValue[]; latestEntityMetric?: DbMetricValue[]; countOfExpiredMetrics?: number; aggregatedMetric?: DbAggregatedMetric; + scalarAggregatedMetric?: DbScalarAggregatedMetric; entityMetricsByStatus?: { rows: DbMetricValue[]; total: number }; }; @@ -30,6 +35,7 @@ export const mockDatabaseMetricValues = { readLatestEntityMetricValues: jest.fn(), cleanupExpiredMetrics: jest.fn(), readAggregatedMetricByEntityRefs: jest.fn(), + readScalarAggregatedMetricByEntityRefs: jest.fn(), readEntityMetricsWithFilters: jest.fn(), } as unknown as jest.Mocked; @@ -38,6 +44,7 @@ export const buildMockDatabaseMetricValues = ({ latestEntityMetric, countOfExpiredMetrics, aggregatedMetric, + scalarAggregatedMetric, entityMetricsByStatus, }: BuildMockDatabaseMetricValuesParams) => { const createMetricValues = metricValues @@ -56,6 +63,10 @@ export const buildMockDatabaseMetricValues = ({ ? jest.fn().mockResolvedValue(aggregatedMetric) : mockDatabaseMetricValues.readAggregatedMetricByEntityRefs; + const readScalarAggregatedMetricByEntityRefs = scalarAggregatedMetric + ? jest.fn().mockResolvedValue(scalarAggregatedMetric) + : mockDatabaseMetricValues.readScalarAggregatedMetricByEntityRefs; + const readEntityMetricsWithFilters = entityMetricsByStatus ? jest.fn().mockResolvedValue(entityMetricsByStatus) : mockDatabaseMetricValues.readEntityMetricsWithFilters; @@ -65,6 +76,7 @@ export const buildMockDatabaseMetricValues = ({ readLatestEntityMetricValues, cleanupExpiredMetrics, readAggregatedMetricByEntityRefs, + readScalarAggregatedMetricByEntityRefs, readEntityMetricsWithFilters, } as unknown as jest.Mocked; }; diff --git a/workspaces/scorecard/plugins/scorecard-backend/config.d.ts b/workspaces/scorecard/plugins/scorecard-backend/config.d.ts index 723486a303..0c8ce92a7a 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/config.d.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/config.d.ts @@ -16,7 +16,7 @@ import { SchedulerServiceTaskScheduleDefinitionConfig } from '@backstage/backend-plugin-api'; import { - AggregationType, + aggregationTypes, ThresholdConfig, AggregationThresholdRule, } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; @@ -27,30 +27,63 @@ export interface Config { /** Configuration for scorecard aggregation KPIs */ aggregationKPIs?: { /** Unique identifier for scorecard aggregation KPIs */ - [aggregationId: string]: { - /** Title of the aggregation */ - title: string; - /** Description of the aggregation */ - description: string; - /** Type of the aggregation */ - type: AggregationType; - /** Metric ID for which the aggregation is calculated */ - metricId: string; - /** Type-specific settings */ - options?: { - /** Required under `options` when `type` is `weightedStatusScore` */ - statusScores?: { - [thresholdRuleKey: string]: number; + [aggregationId: string]: + | { + /** Title of the aggregation */ + title: string; + /** Description of the aggregation */ + description: string; + /** Metric ID for which the aggregation is calculated */ + metricId: string; + /** Status grouped aggregation type */ + type: typeof aggregationTypes.statusGrouped; + } + | { + /** Title of the aggregation */ + title: string; + /** Description of the aggregation */ + description: string; + /** Metric ID for which the aggregation is calculated */ + metricId: string; + /** Weighted status score aggregation type */ + type: typeof aggregationTypes.weightedStatusScore; + /** Options specific to the weighted status score aggregation type */ + options: { + /** Required: Status scores for the aggregation */ + statusScores: { + [thresholdRuleKey: string]: number; + }; + /** + * Optional: threshold rules for coloring the KPI headline value from the aggregation result + * (e.g. weighted status score percentage 0–100). + */ + thresholds?: { + rules: AggregationThresholdRule[]; + }; + }; + } + | { + /** Title of the aggregation */ + title: string; + /** Description of the aggregation */ + description: string; + /** Metric ID for which the aggregation is calculated */ + metricId: string; + /** Scalar aggregation type */ + type: + | typeof aggregationTypes.sum + | typeof aggregationTypes.average + | typeof aggregationTypes.max + | typeof aggregationTypes.min + | typeof aggregationTypes.count; + /** Options specific to the scalar aggregation type */ + options?: { + /** Optional: threshold rules for coloring the KPI headline value from the aggregation result */ + thresholds?: { + rules: AggregationThresholdRule[]; + }; + }; }; - /** - * Optional: threshold rules for coloring the KPI headline value from the aggregation result - * (e.g. weighted status score percentage 0–100 for `weightedStatusScore` KPIs). - */ - thresholds?: { - rules: AggregationThresholdRule[]; - }; - }; - }; }; /** Number of days to retain metric data in the database. Older data will be automatically cleaned up. Default: 365 days */ dataRetentionDays?: number; diff --git a/workspaces/scorecard/plugins/scorecard-backend/docs/aggregation.md b/workspaces/scorecard/plugins/scorecard-backend/docs/aggregation.md index 19ea6e074f..018f320b57 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/docs/aggregation.md +++ b/workspaces/scorecard/plugins/scorecard-backend/docs/aggregation.md @@ -36,10 +36,45 @@ KPIs under **`scorecard.aggregationKPIs`** declare a **`type`** that selects an **`weightedStatusScore`** rolls up each owned entity’s metric into status keys, applies **`options.statusScores`** (weights per status key), and returns **one normalized score** as a **percentage** in \[0, 100\] (one decimal), scaled against the metric’s threshold rules. Use it when you want a single “portfolio health” number (for example a donut gauge on the homepage). +**Scalar types** (`sum`, `average`, `max`, `min`, `count`) roll up each owned entity’s **latest numeric metric value** into a single number (or entity count for `count`). Use them when you want portfolio totals, averages, extremes, or entity counts without a per-status breakdown. + | Type | Output | Typical use | | ------------------------- | ---------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | | **`statusGrouped`** | Counts per status key across owned entities | “How many entities are green vs red” style pie. | | **`weightedStatusScore`** | **`weightedStatusScore`** in \[0, 100\] (percent, one decimal) from weighted counts via **`statusScores`** | Portfolio health gauge from one headline score. | +| **`sum`** | Single numeric total of latest metric values across owned entities | “Total open bugs across my portfolio.” | +| **`average`** | Mean of latest metric values across owned entities | “Average open PRs per entity.” | +| **`max`** | Maximum latest metric value across owned entities | “Worst-case / highest value in the portfolio.” | +| **`min`** | Minimum latest metric value across owned entities | “Best-case / lowest value in the portfolio.” | +| **`count`** | Number of entities with a non-null latest stored value | “How many entities have data for this metric.” | + +**Scalar types** (`sum`, `average`, `max`, `min`, `count`) aggregate the **numeric `value`** from each owned entity’s **latest** stored `metric_values` row for the configured **`metricId`**, instead of bucketing by threshold status. Clients can detect scalar responses by checking **`metadata.aggregationType`** against the scalar type literals (or `scalarAggregationTypes` from scorecard-common).\*\*\*\* + +For scalar types: + +1. **Latest row per entity:** Same scope as other aggregation KPIs — one row per owned catalog entity ref (the row with the highest `id` for that entity and metric). +2. **Calculation failures excluded:** Rows where `error_message` is set and `value` is null are excluded from the aggregate (same rule as status-grouped aggregation). +3. **SQL function:** `sum` → `SUM(value)`, `average` → `AVG(value)`, `max` → `MAX(value)`, `min` → `MIN(value)`, `count` → `COUNT(*)` over rows with a non-null value. +4. **Metric type rules:** All scalar types (`sum`, `average`, `max`, `min`, `count`) require a **number** metric. Startup validation rejects scalar KPIs that target a boolean metric. +5. **Optional result thresholds:** `options.thresholds` (number-style rules) can color or classify the aggregated **`value`**. When omitted, the API returns **`DEFAULT_NUMBER_THRESHOLDS`**. See [thresholds.md — Aggregation KPI result thresholds (scalar types)](./thresholds.md#5-aggregation-kpi-result-thresholds-scalar-types). + +Example scalar KPI config: + +```yaml +scorecard: + aggregationKPIs: + totalOpenBugs: + title: Total Open Bugs + description: Sum of open issues across owned entities + type: sum + metricId: jira.open_issues + + avgOpenPrs: + title: Average Open PRs + description: Mean open PR count per entity + type: average + metricId: github.open_prs +``` For **`weightedStatusScore`**: @@ -51,9 +86,9 @@ For **`weightedStatusScore`**: ## Configuration validation -**`scorecard.aggregationKPIs`** is validated when the backend plugin starts. Invalid entries (unknown **`type`**, missing **`options`** for **`weightedStatusScore`**, empty **`statusScores`**, unknown **`metricId`**, invalid threshold expressions, etc.) cause startup to **fail with an error** so misconfiguration is caught early. Fix app-config and redeploy. +**`scorecard.aggregationKPIs`** is validated when the backend plugin starts. Invalid entries (unknown **`type`**, missing **`options`** for **`weightedStatusScore`**, empty **`statusScores`**, unknown **`metricId`**, any scalar type on a **boolean** metric, invalid threshold expressions, etc.) cause startup to **fail with an error** so misconfiguration is caught early. Fix app-config and redeploy. -For **`type: weightedStatusScore`**, optional **`options.thresholds`** must satisfy the same **number interval / gap** rules as metric thresholds when multiple rules apply (union must cover the full real line with no gaps). Errors mention an approximate **first uncovered region**. See [Joint coverage (number metrics)](./thresholds.md#joint-coverage-number-metrics). +For **`type: weightedStatusScore`** or any **scalar type** with optional **`options.thresholds`**, threshold rules must satisfy the same **number interval / gap** rules as metric thresholds when multiple rules apply (union must cover the full real line with no gaps). Errors mention an approximate **first uncovered region**. See [Joint coverage (number metrics)](./thresholds.md#joint-coverage-number-metrics). Schema reference for config discovery (IDE / `backstage-cli config:schema`): see **`config.d.ts`** on the backend package (`aggregationKPIs` and nested **`options`**). @@ -66,7 +101,13 @@ Use this endpoint for all new integrations. - **`aggregationId`** may be a key under **`scorecard.aggregationKPIs`** in app-config (see the [backend README](../README.md#aggregation-kpis-homepage-and-get-aggregations)), which supplies **title**, **description**, **type**, **metricId**, and for **`type: weightedStatusScore`** the **`options.statusScores`** map (threshold rule key → weight), with room for more **`options`** fields per type later. - If there is **no** `scorecard.aggregationKPIs.` block, the backend still responds successfully: it treats **`aggregationId` as the `metricId`** and uses the default **statusGrouped** strategy (same as calling **`/aggregations/`** with a metric id). A **warning** is logged on the server so missing KPI config is visible in operator logs. To get a custom **title**, **`weightedStatusScore`** type, or other KPI options, you must add that block; a typo in the id falls through to this default and can look like “wrong” aggregation behavior in the UI, so check logs and app-config. -The response shape includes **`id`**, **`status`**, **`metadata`** (title, description, type, aggregation type), and **`result`** (counts per threshold rule, total, thresholds). The **`result`** object also includes **`entitiesConsidered`** (count of in-scope owned entities that have **at least one** latest `metric_values` row for this metric) and **`calculationErrorCount`** (how many of those latest rows are metric calculation failures: `error_message` set and `value` null), so the homepage ratio matches the population behind the drill-down table rather than the raw number of owned catalog refs. For **`weightedStatusScore`**, **`result`** also includes **`weightedStatusScore`** (portfolio percentage in \[0, 100\], one decimal), **`weightedStatusSum`**, and **`weightedStatusMaxPossible`** (see backend README). The homepage card shows a donut gauge for this type instead of a multi-slice status pie. +The response shape includes **`id`**, **`status`**, **`metadata`** (title, description, type, aggregation type), and **`result`**. The shape of **`result`** depends on the aggregation type: + +- **`statusGrouped`**: counts per threshold rule, **`total`**, **`thresholds`**, **`entitiesConsidered`**, **`calculationErrorCount`**, **`timestamp`**. +- **`weightedStatusScore`**: same as status-grouped, plus **`weightedStatusScore`** (portfolio percentage in \[0, 100\], one decimal), **`weightedStatusSum`**, **`weightedStatusMaxPossible`**, and **`aggregationChartDisplayColor`** (see backend README). The homepage card shows a donut gauge for this type instead of a multi-slice status pie. +- **Scalar types** (`sum`, `average`, `max`, `min`, `count`): **`value`**, **`total`**, **`entitiesConsidered`**, **`calculationErrorCount`**, **`timestamp`**, **`thresholds`**. + +**`entitiesConsidered`** (all types): count of in-scope owned entities that have **at least one** latest `metric_values` row for this metric. **`calculationErrorCount`**: how many of those latest rows are metric calculation failures (`error_message` set and `value` null), so the homepage ratio matches the population behind the drill-down table rather than the raw number of owned catalog refs. For scalar types, **`total`** is the number of rows that contributed to **`value`** (non-null latest values, calculation failures excluded). **“Without calculation errors” on the homepage:** `healthy = entitiesConsidered - calculationErrorCount` counts only among entities that already have a latest stored row for this metric. Owned entities with **no** row yet are omitted from **`entitiesConsidered`** (same as omitting them from the drill-down list until data exists). @@ -86,11 +127,11 @@ Same resolution as above, but returns only metadata fields (no aggregate counts) #### Empty results -When the user owns no relevant entities, the API returns an aggregation with **zero total** and zeroed bucket counts (not an error). +When the user owns no relevant entities, the API returns an aggregation with **zero total** and zeroed bucket counts for distribution types, or **`value: 0`** with zeroed entity counts for scalar types (not an error). ### Drill-down vs aggregation id -The aggregation API uses **`aggregationId`** (KPI key or metric id). **Entity drill-down** remains **metric-scoped**: use **`GET /metrics/:metricId/catalog/aggregations/entities`** with the KPI’s **`metricId`**, not the KPI key. That applies to both **`statusGrouped`** and **`weightedStatusScore`** KPIs. See [drill-down.md](./drill-down.md). +The aggregation API uses **`aggregationId`** (KPI key or metric id). **Entity drill-down** remains **metric-scoped**: use **`GET /metrics/:metricId/catalog/aggregations/entities`** with the KPI’s **`metricId`**, not the KPI key. That applies to **`statusGrouped`**, **`weightedStatusScore`**, and **scalar** KPIs. See [drill-down.md](./drill-down.md). ### **Deprecated API:** `GET /metrics/:metricId/catalog/aggregations` @@ -175,6 +216,6 @@ If the user doesn't have access to the specified metric: 5. **Metric access**: Aggregation routes enforce **`scorecard.metric.read`** for the underlying metric and **`catalog.entity.read`** for each included entity; expect **`403 Forbidden`** when either check fails. -For RBAC, scheduling, full endpoint reference, and **app-config examples** for **`weightedStatusScore`** KPIs (including **`thresholds`**), see the [Scorecard backend README](../README.md). +For RBAC, scheduling, full endpoint reference, and **app-config examples** for **`weightedStatusScore`** and **scalar** KPIs, see the [Scorecard backend README](../README.md). -For **per-entity threshold overrides** (annotations), **weightedStatusScore KPI result thresholds**, and expression reference, see [thresholds.md](./thresholds.md). +For **per-entity threshold overrides** (annotations), **weightedStatusScore** and **scalar** KPI result thresholds, and expression reference, see [thresholds.md](./thresholds.md). diff --git a/workspaces/scorecard/plugins/scorecard-backend/docs/drill-down.md b/workspaces/scorecard/plugins/scorecard-backend/docs/drill-down.md index 8fdb76d93c..03e160acae 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/docs/drill-down.md +++ b/workspaces/scorecard/plugins/scorecard-backend/docs/drill-down.md @@ -6,7 +6,7 @@ The Scorecard plugin provides a drill-down endpoint that returns detailed entity High-level aggregation for homepage KPIs uses **`GET /aggregations/:aggregationId`** (see [aggregation.md](./aggregation.md)). Drill-down is **metric-scoped**: the endpoint **`/metrics/:metricId/catalog/aggregations/entities`** lists entities and values for a single **metric id** (not a KPI id). -**Note:** If the homepage card uses a KPI key (for example **`openPrsWeightedKpi`**) with **`type: weightedStatusScore`**, drill-down still uses the KPI’s configured **`metricId`** (e.g. **`github.open_prs`**) in this path—not the KPI id. +**Note:** If the homepage card uses a KPI key (for example **`openPrsWeightedKpi`**) with **`type: weightedStatusScore`**, drill-down still uses the KPI’s configured **`metricId`** (e.g. **`github.open_prs`**) in this path—not the KPI id. The same applies to **scalar** KPIs (`sum`, `average`, `max`, `min`, `count`): use the KPI’s **`metricId`**, not the KPI key. The drill-down endpoint provides a detailed view of entities and their metric values. It allows managers and platform engineers to: diff --git a/workspaces/scorecard/plugins/scorecard-backend/docs/thresholds.md b/workspaces/scorecard/plugins/scorecard-backend/docs/thresholds.md index fa970a6a02..4fbb03dd61 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/docs/thresholds.md +++ b/workspaces/scorecard/plugins/scorecard-backend/docs/thresholds.md @@ -166,6 +166,20 @@ These thresholds are **not** per-entity metric rules. They apply only to homepag **Further reading:** [Entity Aggregation](./aggregation.md) (`weightedStatusScore` algorithm, API, drill-down); [Scorecard backend README — Aggregation KPIs](../README.md#aggregation-kpis-homepage-and-get-aggregations) (full **`aggregationKPIs`** example including **`statusScores`**). +### 5. Aggregation KPI result thresholds (scalar types) + +These thresholds are **not** per-entity metric rules. They apply to homepage aggregation KPIs where **`scorecard.aggregationKPIs..type`** is one of **`sum`**, **`average`**, **`max`**, **`min`**, or **`count`**. + +**Configuration path:** `scorecard.aggregationKPIs..options.thresholds` + +**YAML shape:** Same as metric thresholds — a **`rules`** array of **`key`**, **`expression`**, and optional **`color`** (and optional **`icon`**). Expressions are **number**-style and are evaluated against **`result.value`**, the aggregated scalar from the KPI (see [Entity Aggregation — Scalar result fields](./aggregation.md#scalar-result-fields)). The **first** matching rule wins; its **`color`** and **`key`** can be used by custom UIs that render scalar KPIs. + +**Defaults:** If **`thresholds`** is omitted from app-config under **`options`**, **`ValueAggregationStrategy`** applies **`DEFAULT_NUMBER_THRESHOLDS`** from scorecard-common when serving an aggregation and includes them on the API as **`result.thresholds`**. + +**Startup validation:** Invalid rules or expressions are caught when the backend plugin loads, together with the rest of **`scorecard.aggregationKPIs`**. Scalar KPI **`options.thresholds`** must also satisfy **joint full-line coverage** for number expressions when multiple rules apply (see [Joint coverage (number metrics)](#joint-coverage-number-metrics)). See [aggregation.md — Configuration validation](./aggregation.md#configuration-validation). + +**Further reading:** [Entity Aggregation](./aggregation.md) (scalar types, API response shape); [Scorecard backend README — Aggregation KPIs](../README.md#aggregation-kpis-homepage-and-get-aggregations) (scalar **`aggregationKPIs`** examples). + ## Threshold Priority Order Thresholds are applied with the following priority (highest to lowest): @@ -429,6 +443,6 @@ rules: ## Related documentation -- [Entity Aggregation](./aggregation.md) — ownership, **`GET /aggregations/:aggregationId`**, **`statusGrouped`** vs **`weightedStatusScore`** +- [Entity Aggregation](./aggregation.md) — ownership, **`GET /aggregations/:aggregationId`**, **`statusGrouped`**, **`weightedStatusScore`**, and scalar types - [Drill-down](./drill-down.md) — entity list for a metric (`metricId`, not KPI id) - [Scorecard backend README](../README.md) — install, RBAC, **`aggregationKPIs`** examples diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/database/DatabaseMetricValues.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/database/DatabaseMetricValues.test.ts index d1452f46ee..d49788b9c9 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/database/DatabaseMetricValues.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/database/DatabaseMetricValues.test.ts @@ -1356,4 +1356,253 @@ describe('DatabaseMetricValues', () => { }, ); }); + + describe('readScalarAggregatedMetricByEntityRefs', () => { + describe.each(databases.eachSupportedId())( + 'should %p raw metric values across latest rows', + databaseId => { + let db: DatabaseMetricValues; + + beforeAll(async () => { + const database = await createDatabase(databaseId); + const { client } = database; + db = database.db; + + await client('metric_values').insert([ + createMetricValue({ + entityRef: 'component:default/service1', + value: 10, + status: 'success', + }), + createMetricValue({ + entityRef: 'component:default/service2', + value: 25, + status: 'warning', + }), + createMetricValue({ + entityRef: 'component:default/service3', + value: 5, + status: 'error', + }), + createMetricValue({ + entityRef: 'component:default/service2', + value: 2, + status: 'success', + }), + ]); + }); + + it('should sum raw metric values across latest rows', async () => { + const result = await db.readScalarAggregatedMetricByEntityRefs( + [ + 'component:default/service1', + 'component:default/service2', + 'component:default/service3', + ], + 'github.metric1', + 'sum', + ); + + expect(result).toEqual({ + metric_id: 'github.metric1', + value: 17, + total: 3, + latest_entity_count: 3, + calculation_error_count: 0, + max_timestamp: baseTimestamp, + }); + }); + + it('should average raw metric values across latest rows', async () => { + const result = await db.readScalarAggregatedMetricByEntityRefs( + [ + 'component:default/service1', + 'component:default/service2', + 'component:default/service3', + ], + 'github.metric1', + 'average', + ); + + expect(result).toEqual({ + metric_id: 'github.metric1', + value: 5.666666666666667, + total: 3, + latest_entity_count: 3, + calculation_error_count: 0, + max_timestamp: baseTimestamp, + }); + }); + + it('should count raw metric values across latest rows', async () => { + const result = await db.readScalarAggregatedMetricByEntityRefs( + [ + 'component:default/service1', + 'component:default/service2', + 'component:default/service3', + ], + 'github.metric1', + 'count', + ); + + expect(result).toEqual({ + metric_id: 'github.metric1', + value: 3, + total: 3, + latest_entity_count: 3, + calculation_error_count: 0, + max_timestamp: baseTimestamp, + }); + }); + + it('should max raw metric values across latest rows', async () => { + const result = await db.readScalarAggregatedMetricByEntityRefs( + [ + 'component:default/service1', + 'component:default/service2', + 'component:default/service3', + ], + 'github.metric1', + 'max', + ); + + expect(result).toEqual({ + metric_id: 'github.metric1', + value: 10, + total: 3, + latest_entity_count: 3, + calculation_error_count: 0, + max_timestamp: baseTimestamp, + }); + }); + + it('should min raw metric values across latest rows', async () => { + const result = await db.readScalarAggregatedMetricByEntityRefs( + [ + 'component:default/service1', + 'component:default/service2', + 'component:default/service3', + ], + 'github.metric1', + 'min', + ); + + expect(result).toEqual({ + metric_id: 'github.metric1', + value: 2, + total: 3, + latest_entity_count: 3, + calculation_error_count: 0, + max_timestamp: baseTimestamp, + }); + }); + }, + ); + + it.each(databases.eachSupportedId())( + 'should exclude calculation failures and use latest row per entity - %p', + async databaseId => { + const { client, db } = await createDatabase(databaseId); + + const olderTime = new Date('2023-01-01T00:00:00Z'); + const newerTime = new Date('2023-01-01T01:00:00Z'); + + await client('metric_values').insert([ + createMetricValue({ + entityRef: 'component:default/service1', + timestamp: olderTime, + value: 5, + status: 'success', + }), + createMetricValue({ + entityRef: 'component:default/service1', + timestamp: newerTime, + value: 15, + status: 'warning', + }), + createMetricValue({ + entityRef: 'component:default/service2', + value: null, + status: null, + errorMessage: 'Failed to fetch', + }), + ]); + + const result = await db.readScalarAggregatedMetricByEntityRefs( + ['component:default/service1', 'component:default/service2'], + 'github.metric1', + 'sum', + ); + + expect(result).toEqual({ + metric_id: 'github.metric1', + value: 15, + total: 1, + latest_entity_count: 2, + calculation_error_count: 1, + max_timestamp: newerTime, + }); + }, + ); + + it.each(databases.eachSupportedId())( + 'should return undefined when entity refs have no metric rows - %p', + async databaseId => { + const { db } = await createDatabase(databaseId); + + const result = await db.readScalarAggregatedMetricByEntityRefs( + ['component:default/service-without-data'], + 'github.metric1', + 'sum', + ); + + expect(result).toBeUndefined(); + }, + ); + + it.each(databases.eachSupportedId())( + 'should exclude rows with null value from aggregate - %p', + async databaseId => { + const { client, db } = await createDatabase(databaseId); + + await client('metric_values').insert([ + createMetricValue({ + entityRef: 'component:default/service1', + value: 10, + status: 'success', + }), + createMetricValue({ + entityRef: 'component:default/service2', + value: null, + status: null, + }), + ]); + + const result = await db.readScalarAggregatedMetricByEntityRefs( + ['component:default/service1', 'component:default/service2'], + 'github.metric1', + 'sum', + ); + + expect(result?.value).toBe(10); + expect(result?.total).toBe(1); + expect(result?.latest_entity_count).toBe(2); + }, + ); + + it.each(databases.eachSupportedId())( + 'should return undefined when entity refs list is empty - %p', + async databaseId => { + const { db } = await createDatabase(databaseId); + + const result = await db.readScalarAggregatedMetricByEntityRefs( + [], + 'github.metric1', + 'sum', + ); + + expect(result).toBeUndefined(); + }, + ); + }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/database/DatabaseMetricValues.ts b/workspaces/scorecard/plugins/scorecard-backend/src/database/DatabaseMetricValues.ts index 63550a76af..66b0c970c6 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/database/DatabaseMetricValues.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/database/DatabaseMetricValues.ts @@ -19,7 +19,12 @@ import { DbMetricValueCreate, DbMetricValue, DbAggregatedMetric, + DbScalarAggregatedMetric, + ScalarAggregationFn, } from './types'; +import { normalizeTimestamp } from './utils/normalizeTimestamp'; +import { mergeMaxTimestamp } from './utils/mergeMaxTimestamp'; +import { getAggregateExpression } from './utils/getAggregateExpression'; type ReadEntityMetricsWithFiltersOptions = { status?: string; @@ -39,11 +44,123 @@ type ReadEntityMetricsWithFiltersOptions = { pagination?: { limit: number; offset: number }; }; +type StatsRowResult = { + latestIdsSubquery: Knex.QueryBuilder; + latestRowCount: number; + calculation_error_count: number; + maxTimestampAllLatest: Date; +}; + +type ScalarAggregationRowResult = { + value: number; + total: number; + maxTimestamp: Date; +}; + export class DatabaseMetricValues { private readonly tableName = 'metric_values'; constructor(private readonly dbClient: Knex) {} + /** + * Get the latest ids subquery for a given metric and catalog entity refs + */ + private getLatestIdsSubquery( + metric_id: string, + catalog_entity_refs: string[], + ): Knex.QueryBuilder { + return this.dbClient(this.tableName) + .max('id') + .where('metric_id', metric_id) + .whereIn('catalog_entity_ref', catalog_entity_refs) + .groupBy('catalog_entity_ref'); + } + + /** + * Get the stats row for a given latest ids subquery + */ + private async readStatsRowByLatestIdsSubquery( + latestIdsSubquery: Knex.QueryBuilder, + metricValueIsMissingExpr: string, + ): Promise { + // One round-trip for latest-row count, calculation-error count, and max timestamp + // (same latest-id set as the status breakdown query below). + const statsRow = await this.dbClient(this.tableName) + .whereIn('id', latestIdsSubquery) + .select( + this.dbClient.raw('COUNT(*) as latest_row_count'), + this.dbClient.raw( + `SUM(CASE WHEN error_message IS NOT NULL AND ${metricValueIsMissingExpr} THEN 1 ELSE 0 END) as calculation_error_count`, + ), + this.dbClient.raw('MAX(timestamp) as max_timestamp'), + ) + .first(); + + const latestRowCount = Number( + (statsRow as { latest_row_count?: string | number } | undefined) + ?.latest_row_count ?? 0, + ); + + const calculation_error_count = Number( + (statsRow as { calculation_error_count?: string | number } | undefined) + ?.calculation_error_count ?? 0, + ); + + const maxTimestampAllLatest = normalizeTimestamp( + (statsRow as { max_timestamp?: unknown })?.max_timestamp, + ); + + return { + latestIdsSubquery, + latestRowCount, + calculation_error_count, + maxTimestampAllLatest, + }; + } + + private async readScalarAggregationByLatestIdsSubquery( + latestIdsSubquery: Knex.QueryBuilder, + metricValueIsMissingExpr: string, + aggregationFn: ScalarAggregationFn, + ): Promise { + const clientName: string = + (this.dbClient as any).client?.config?.client ?? ''; + const isPostgres = clientName === 'pg' || clientName.includes('postgres'); + + const numericValueExpr = isPostgres + ? 'CAST(value::text AS DOUBLE PRECISION)' + : 'CAST(CAST(value AS TEXT) AS REAL)'; + + const aggregateExpression = getAggregateExpression( + aggregationFn, + numericValueExpr, + ); + + const aggregateQuery = this.dbClient(this.tableName) + .whereIn('id', latestIdsSubquery) + .whereRaw(`NOT ${metricValueIsMissingExpr}`); + + const aggregateRow = await aggregateQuery + .select( + this.dbClient.raw(`${aggregateExpression} as value`), + this.dbClient.raw('COUNT(*) as total'), + this.dbClient.raw('MAX(timestamp) as max_timestamp'), + ) + .first(); + + const value = aggregateRow?.value ? Number(aggregateRow.value) : 0; + const total = aggregateRow?.total ? Number(aggregateRow.total) : 0; + const maxTimestamp = aggregateRow?.max_timestamp + ? normalizeTimestamp(aggregateRow.max_timestamp) + : new Date(0); + + return { + value, + total, + maxTimestamp, + }; + } + /** * Insert multiple metric values */ @@ -93,60 +210,25 @@ export class DatabaseMetricValues { return undefined; } - const latestIdsSubquery = this.dbClient(this.tableName) - .max('id') - .where('metric_id', metric_id) - .whereIn('catalog_entity_ref', catalog_entity_refs) - .groupBy('catalog_entity_ref'); - // `value` is a JSON column. Depending on database/driver, a "missing" metric value can // arrive either as SQL NULL or as JSON literal null (`CAST(value AS TEXT) = 'null'`). const metricValueIsMissingExpr = "(value IS NULL OR CAST(value AS TEXT) = 'null')"; - // One round-trip for latest-row count, calculation-error count, and max timestamp - // (same latest-id set as the status breakdown query below). - const statsRow = await this.dbClient(this.tableName) - .whereIn('id', latestIdsSubquery) - .select( - this.dbClient.raw('COUNT(*) as latest_row_count'), - this.dbClient.raw( - `SUM(CASE WHEN error_message IS NOT NULL AND ${metricValueIsMissingExpr} THEN 1 ELSE 0 END) as calculation_error_count`, - ), - this.dbClient.raw('MAX(timestamp) as max_timestamp'), - ) - .first(); - - const latestRowCount = Number( - (statsRow as { latest_row_count?: string | number } | undefined) - ?.latest_row_count ?? 0, + const latestIdsSubquery = this.getLatestIdsSubquery( + metric_id, + catalog_entity_refs, ); + const { latestRowCount, calculation_error_count, maxTimestampAllLatest } = + await this.readStatsRowByLatestIdsSubquery( + latestIdsSubquery, + metricValueIsMissingExpr, + ); + if (latestRowCount === 0) { return undefined; } - const calculation_error_count = Number( - (statsRow as { calculation_error_count?: string | number } | undefined) - ?.calculation_error_count ?? 0, - ); - - // Normalize types for cross-database compatibility - // PostgreSQL returns COUNT/SUM as strings, SQLite returns numbers - // PostgreSQL returns MAX(timestamp) as Date, SQLite returns number (milliseconds) - const normalizeTimestamp = (timestamp: unknown): Date => { - if (timestamp instanceof Date) { - return timestamp; - } - if (typeof timestamp === 'number' || typeof timestamp === 'string') { - return new Date(timestamp); - } - return new Date(); - }; - - const maxTimestampAllLatest = normalizeTimestamp( - (statsRow as { max_timestamp?: unknown })?.max_timestamp, - ); - const statusRows = await this.dbClient(this.tableName) .select('status') .count('* as count') @@ -181,10 +263,7 @@ export class DatabaseMetricValues { total += count; } - const mergedMax = - maxTimestampAllLatest.getTime() >= maxTimestamp.getTime() - ? maxTimestampAllLatest - : maxTimestamp; + const mergedMax = mergeMaxTimestamp(maxTimestampAllLatest, maxTimestamp); return { metric_id, @@ -196,6 +275,62 @@ export class DatabaseMetricValues { }; } + /** + * Aggregate raw metric values across latest rows for multiple entities. + */ + async readScalarAggregatedMetricByEntityRefs( + catalog_entity_refs: string[], + metric_id: string, + aggregationFn: ScalarAggregationFn, + ): Promise { + if (catalog_entity_refs.length === 0) { + return undefined; + } + + // `value` is a JSON column. Depending on database/driver, a "missing" metric value can + // arrive either as SQL NULL or as JSON literal null (`CAST(value AS TEXT) = 'null'`). + const metricValueIsMissingExpr = + "(value IS NULL OR CAST(value AS TEXT) = 'null')"; + + const latestIdsSubquery = this.getLatestIdsSubquery( + metric_id, + catalog_entity_refs, + ); + const { latestRowCount, calculation_error_count, maxTimestampAllLatest } = + await this.readStatsRowByLatestIdsSubquery( + latestIdsSubquery, + metricValueIsMissingExpr, + ); + + if (latestRowCount === 0) { + return undefined; + } + + const { + value, + total, + maxTimestamp: aggregateMaxTimestamp, + } = await this.readScalarAggregationByLatestIdsSubquery( + latestIdsSubquery, + metricValueIsMissingExpr, + aggregationFn, + ); + + const mergedMax = mergeMaxTimestamp( + maxTimestampAllLatest, + aggregateMaxTimestamp, + ); + + return { + metric_id, + total, + max_timestamp: mergedMax, + value, + calculation_error_count, + latest_entity_count: latestRowCount, + }; + } + /** * Fetch the latest entity metric values for a given metric, with optional filtering * by status, name, kind, namespace, or owner, plus sorting and pagination. diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/database/types.ts b/workspaces/scorecard/plugins/scorecard-backend/src/database/types.ts index 0acb8eda7d..9effbbcbbb 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/database/types.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/database/types.ts @@ -54,3 +54,14 @@ export type DbAggregatedMetric = { */ latest_entity_count: number; }; + +export type ScalarAggregationFn = 'sum' | 'average' | 'max' | 'min' | 'count'; + +export type DbScalarAggregatedMetric = { + metric_id: string; + total: number; + max_timestamp: Date; + value: number; + calculation_error_count: number; + latest_entity_count: number; +}; diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/database/utils/getAggregateExpression.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/database/utils/getAggregateExpression.test.ts new file mode 100644 index 0000000000..9c176d8d73 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend/src/database/utils/getAggregateExpression.test.ts @@ -0,0 +1,39 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getAggregateExpression } from './getAggregateExpression'; + +describe('getAggregateExpression', () => { + const numericValueExpr = 'CAST(value AS REAL)'; + + it.each([ + ['sum', `SUM(${numericValueExpr})`], + ['average', `AVG(${numericValueExpr})`], + ['max', `MAX(${numericValueExpr})`], + ['min', `MIN(${numericValueExpr})`], + ['count', 'COUNT(*)'], + ] as const)('should map %s to SQL expression', (aggregationFn, expected) => { + expect(getAggregateExpression(aggregationFn, numericValueExpr)).toBe( + expected, + ); + }); + + it('should throw for invalid aggregation function', () => { + expect(() => + getAggregateExpression('invalid' as 'sum', numericValueExpr), + ).toThrow('Invalid aggregation function: invalid'); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/database/utils/getAggregateExpression.ts b/workspaces/scorecard/plugins/scorecard-backend/src/database/utils/getAggregateExpression.ts new file mode 100644 index 0000000000..ba6c470bd5 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend/src/database/utils/getAggregateExpression.ts @@ -0,0 +1,37 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ScalarAggregationFn } from '../types'; + +export function getAggregateExpression( + aggregationFn: ScalarAggregationFn, + numericValueExpr: string, +): string { + switch (aggregationFn) { + case 'count': + return 'COUNT(*)'; + case 'sum': + return `SUM(${numericValueExpr})`; + case 'average': + return `AVG(${numericValueExpr})`; + case 'max': + return `MAX(${numericValueExpr})`; + case 'min': + return `MIN(${numericValueExpr})`; + default: + throw new Error(`Invalid aggregation function: ${aggregationFn}`); + } +} diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/database/utils/mergeMaxTimestamp.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/database/utils/mergeMaxTimestamp.test.ts new file mode 100644 index 0000000000..b49cd50906 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend/src/database/utils/mergeMaxTimestamp.test.ts @@ -0,0 +1,34 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { mergeMaxTimestamp } from './mergeMaxTimestamp'; + +describe('mergeMaxTimestamp', () => { + it('should return the later timestamp', () => { + const earlier = new Date('2023-01-01T00:00:00Z'); + const later = new Date('2023-01-02T00:00:00Z'); + + expect(mergeMaxTimestamp(earlier, later)).toBe(later); + expect(mergeMaxTimestamp(later, earlier)).toBe(later); + }); + + it('should return the first timestamp when both are equal', () => { + const timestamp = new Date('2023-01-01T00:00:00Z'); + const same = new Date('2023-01-01T00:00:00Z'); + + expect(mergeMaxTimestamp(timestamp, same)).toBe(timestamp); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/database/utils/mergeMaxTimestamp.ts b/workspaces/scorecard/plugins/scorecard-backend/src/database/utils/mergeMaxTimestamp.ts new file mode 100644 index 0000000000..7ac64e7321 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend/src/database/utils/mergeMaxTimestamp.ts @@ -0,0 +1,24 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export function mergeMaxTimestamp( + firstTimestamp: Date, + secondTimestamp: Date, +): Date { + return firstTimestamp.getTime() >= secondTimestamp.getTime() + ? firstTimestamp + : secondTimestamp; +} diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/database/utils/normalizeTimestamp.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/database/utils/normalizeTimestamp.test.ts new file mode 100644 index 0000000000..00369058a8 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend/src/database/utils/normalizeTimestamp.test.ts @@ -0,0 +1,55 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { normalizeTimestamp } from './normalizeTimestamp'; + +describe('normalizeTimestamp', () => { + it('should return the same Date instance when input is a Date', () => { + const timestamp = new Date('2023-01-01T00:00:00Z'); + + expect(normalizeTimestamp(timestamp)).toBe(timestamp); + }); + + it('should parse ISO string timestamps', () => { + const result = normalizeTimestamp('2023-01-01T00:00:00.000Z'); + + expect(result).toEqual(new Date('2023-01-01T00:00:00.000Z')); + }); + + it('should parse numeric epoch timestamps', () => { + const epoch = Date.parse('2023-01-01T00:00:00.000Z'); + + expect(normalizeTimestamp(epoch)).toEqual( + new Date('2023-01-01T00:00:00.000Z'), + ); + }); + + it('should return current time when input is undefined', () => { + const before = Date.now(); + const result = normalizeTimestamp(undefined); + const after = Date.now(); + + expect(result).toBeInstanceOf(Date); + expect(result.getTime()).toBeGreaterThanOrEqual(before); + expect(result.getTime()).toBeLessThanOrEqual(after); + }); + + it('returns current time for unsupported input types', () => { + const result = normalizeTimestamp({} as unknown as Date); + + expect(result).toBeInstanceOf(Date); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/database/utils/normalizeTimestamp.ts b/workspaces/scorecard/plugins/scorecard-backend/src/database/utils/normalizeTimestamp.ts new file mode 100644 index 0000000000..ba68cc3b02 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend/src/database/utils/normalizeTimestamp.ts @@ -0,0 +1,27 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export function normalizeTimestamp(timestamp?: unknown): Date { + if (timestamp instanceof Date) { + return timestamp; + } + + if (typeof timestamp === 'number' || typeof timestamp === 'string') { + return new Date(timestamp); + } + + return new Date(); +} diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/AggregatedMetricLoader.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/AggregatedMetricLoader.test.ts index 71976ac2de..3f45b02d16 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/AggregatedMetricLoader.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/AggregatedMetricLoader.test.ts @@ -15,11 +15,14 @@ */ import { AggregatedMetricLoader } from './AggregatedMetricLoader'; -import type { DbAggregatedMetric } from '../../database/types'; +import type { + DbAggregatedMetric, + DbScalarAggregatedMetric, +} from '../../database/types'; import type { DatabaseMetricValues } from '../../database/DatabaseMetricValues'; describe('AggregatedMetricLoader', () => { - it('returns empty aggregation when entityRefs is empty without calling DB', async () => { + it('should return empty aggregation when entityRefs is empty without calling DB', async () => { const readAggregatedMetricByEntityRefs = jest.fn(); const loader = new AggregatedMetricLoader({ readAggregatedMetricByEntityRefs, @@ -36,7 +39,7 @@ describe('AggregatedMetricLoader', () => { expect(typeof result.timestamp).toBe('string'); }); - it('reads DB and maps rows', async () => { + it('should read DB and map rows', async () => { const row: DbAggregatedMetric = { metric_id: 'm', total: 3, @@ -63,4 +66,87 @@ describe('AggregatedMetricLoader', () => { expect(result.total).toBe(3); expect(result.values).toEqual({ success: 3 }); }); + + it('should return empty scalar aggregation when entityRefs is empty without calling DB', async () => { + const readScalarAggregatedMetricByEntityRefs = jest.fn(); + + const loader = new AggregatedMetricLoader({ + readScalarAggregatedMetricByEntityRefs, + } as unknown as DatabaseMetricValues); + + const result = await loader.loadScalarMetricByEntityRefs( + [], + 'metric.id', + 'sum', + ); + + expect(readScalarAggregatedMetricByEntityRefs).not.toHaveBeenCalled(); + expect(result.value).toBe(0); + expect(result.total).toBe(0); + expect(result.entitiesConsidered).toBe(0); + }); + + it('should read scalar DB row and maps result', async () => { + const row: DbScalarAggregatedMetric = { + metric_id: 'm', + value: 847, + total: 42, + latest_entity_count: 45, + calculation_error_count: 3, + max_timestamp: new Date('2025-01-01T10:30:00.000Z'), + }; + + const readScalarAggregatedMetricByEntityRefs = jest + .fn() + .mockResolvedValue(row); + + const loader = new AggregatedMetricLoader({ + readScalarAggregatedMetricByEntityRefs, + } as unknown as DatabaseMetricValues); + + const result = await loader.loadScalarMetricByEntityRefs( + ['component:default/a'], + 'metric.id', + 'sum', + ); + + expect(readScalarAggregatedMetricByEntityRefs).toHaveBeenCalledWith( + ['component:default/a'], + 'metric.id', + 'sum', + ); + expect(result.value).toBe(847); + expect(result.total).toBe(42); + expect(result.entitiesConsidered).toBe(45); + expect(result.calculationErrorCount).toBe(3); + }); + + it('should map undefined scalar DB row to zero defaults', async () => { + const readScalarAggregatedMetricByEntityRefs = jest + .fn() + .mockResolvedValue(undefined); + + const loader = new AggregatedMetricLoader({ + readScalarAggregatedMetricByEntityRefs, + } as unknown as DatabaseMetricValues); + + const result = await loader.loadScalarMetricByEntityRefs( + ['component:default/a'], + 'metric.id', + 'sum', + ); + + expect(readScalarAggregatedMetricByEntityRefs).toHaveBeenCalledWith( + ['component:default/a'], + 'metric.id', + 'sum', + ); + expect(result).toEqual({ + value: 0, + total: 0, + entitiesConsidered: 0, + calculationErrorCount: 0, + timestamp: expect.any(String), + }); + }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/AggregatedMetricLoader.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/AggregatedMetricLoader.ts index 4b505eca2c..316845d3a3 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/AggregatedMetricLoader.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/AggregatedMetricLoader.ts @@ -14,8 +14,12 @@ * limitations under the License. */ -import type { AggregatedMetric } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import type { + AggregatedMetric, + ScalarAggregatedMetric, +} from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; import { DatabaseMetricValues } from '../../database/DatabaseMetricValues'; +import type { ScalarAggregationFn } from '../../database/types'; import { AggregatedMetricMapper } from '../mappers'; export class AggregatedMetricLoader { @@ -37,4 +41,23 @@ export class AggregatedMetricLoader { return AggregatedMetricMapper.toAggregatedMetric(aggregatedMetric); } + + async loadScalarMetricByEntityRefs( + entityRefs: string[], + metricId: string, + aggregationFn: ScalarAggregationFn, + ): Promise { + if (entityRefs.length === 0) { + return AggregatedMetricMapper.toScalarAggregatedMetric(); + } + + const scalarMetric = + await this.database.readScalarAggregatedMetricByEntityRefs( + entityRefs, + metricId, + aggregationFn, + ); + + return AggregatedMetricMapper.toScalarAggregatedMetric(scalarMetric); + } } diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/AggregationService.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/AggregationService.ts index b96b3ba29d..58694b7f56 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/AggregationService.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/AggregationService.ts @@ -14,9 +14,10 @@ * limitations under the License. */ +import type { AggregationRuntimeConfig } from './types'; +import { parseValidatedAggregationConfig } from '../../validation/validateAggregationConfig'; import { type AggregatedMetricResult, - type AggregationConfig, type AggregationType, aggregationTypes, } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; @@ -52,7 +53,7 @@ export class AggregationsService { ); } - getAggregationConfig(aggregationId: string): AggregationConfig { + getAggregationConfig(aggregationId: string): AggregationRuntimeConfig { const config = this.config.getOptionalConfig( `${AGGREGATION_KPIS_CONFIG_PATH}.${aggregationId}`, ); @@ -67,12 +68,14 @@ export class AggregationsService { id: aggregationId, type: aggregationTypes.statusGrouped, metricId: aggregationId, - } as AggregationConfig; + }; } - return buildAggregationConfig(aggregationId, { - config, - }); + return parseValidatedAggregationConfig( + buildAggregationConfig(aggregationId, { + config, + }), + ); } async getAggregatedMetricByEntityRefs( diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/AggregationsService.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/AggregationsService.test.ts index ca47ec11eb..4084347bdd 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/AggregationsService.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/AggregationsService.test.ts @@ -21,19 +21,30 @@ import { type WeightedStatusScoreAggregationResult, Metric, ThresholdConfig, - type AggregationConfig, + DEFAULT_NUMBER_THRESHOLDS, } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; -import { DEFAULT_WEIGHTED_STATUS_SCORE_KPI_RESULT_THRESHOLDS } from '../../constants/aggregationKPIs'; import { AggregationsService } from './AggregationService'; import type { DatabaseMetricValues } from '../../database/DatabaseMetricValues'; -import type { DbAggregatedMetric } from '../../database/types'; -import { AggregationOptions } from './types'; +import type { + DbAggregatedMetric, + DbScalarAggregatedMetric, +} from '../../database/types'; +import { + mockFallbackStatusGroupedAggregationConfig, + mockScalarAggregationConfig, + mockWeightedStatusScoreAggregationConfig, +} from '../../../__fixtures__/mockAggregationConfig'; +import { isValidatedAggregationConfig } from './utils/aggregationRuntimeConfig'; -function createDatabaseMock( - readAggregatedMetricByEntityRefs: jest.Mock, -): DatabaseMetricValues { +function createDatabaseMock(options: { + readAggregatedMetricByEntityRefs?: jest.Mock; + readScalarAggregatedMetricByEntityRefs?: jest.Mock; +}): DatabaseMetricValues { return { - readAggregatedMetricByEntityRefs, + readAggregatedMetricByEntityRefs: + options.readAggregatedMetricByEntityRefs ?? jest.fn(), + readScalarAggregatedMetricByEntityRefs: + options.readScalarAggregatedMetricByEntityRefs ?? jest.fn(), } as unknown as DatabaseMetricValues; } @@ -53,121 +64,168 @@ describe('AggregationsService', () => { ], }; - it('getAggregatedMetricByEntityRefs loads via DB and maps through statusGrouped strategy', async () => { - const dbRow: DbAggregatedMetric = { - metric_id: metric.id, - total: 3, - max_timestamp: new Date('2025-01-01T10:00:00.000Z'), - statusCounts: { error: 1, warning: 1, success: 1 }, - calculation_error_count: 1, - latest_entity_count: 1, - }; - const readAggregatedMetricByEntityRefs = jest.fn().mockResolvedValue(dbRow); - - const service = new AggregationsService({ - config: mockServices.rootConfig({ data: {} }), - database: createDatabaseMock(readAggregatedMetricByEntityRefs), - logger: mockServices.logger.mock(), - }); + describe('getAggregatedMetricByEntityRefs', () => { + it('should load via DB and maps through statusGrouped strategy', async () => { + const dbRow: DbAggregatedMetric = { + metric_id: metric.id, + total: 3, + max_timestamp: new Date('2025-01-01T10:00:00.000Z'), + statusCounts: { error: 1, warning: 1, success: 1 }, + calculation_error_count: 1, + latest_entity_count: 1, + }; + const readAggregatedMetricByEntityRefs = jest + .fn() + .mockResolvedValue(dbRow); - const result = await service.getAggregatedMetricByEntityRefs({ - metric, - entityRefs: ['component:default/a'], - thresholds, - aggregationConfig: { - id: metric.id, - metricId: metric.id, - type: aggregationTypes.statusGrouped, - } as any, - }); + const service = new AggregationsService({ + config: mockServices.rootConfig({ data: {} }), + database: createDatabaseMock({ readAggregatedMetricByEntityRefs }), + logger: mockServices.logger.mock(), + }); - expect(readAggregatedMetricByEntityRefs).toHaveBeenCalledWith( - ['component:default/a'], - metric.id, - ); - expect(result.id).toBe(metric.id); - expect(result.metadata?.aggregationType).toBe( - aggregationTypes.statusGrouped, - ); - }); + const result = await service.getAggregatedMetricByEntityRefs({ + metric, + entityRefs: ['component:default/a'], + thresholds, + aggregationConfig: mockFallbackStatusGroupedAggregationConfig({ + id: metric.id, + metricId: metric.id, + }), + }); - it('getAggregatedMetricByEntityRefs uses weightedStatusScore strategy when configured', async () => { - const dbRow: DbAggregatedMetric = { - metric_id: metric.id, - total: 3, - max_timestamp: new Date('2025-01-01T10:00:00.000Z'), - statusCounts: { error: 1, warning: 1, success: 1 }, - calculation_error_count: 1, - latest_entity_count: 1, - }; - const readAggregatedMetricByEntityRefs = jest.fn().mockResolvedValue(dbRow); - - const service = new AggregationsService({ - config: mockServices.rootConfig({ data: {} }), - database: createDatabaseMock(readAggregatedMetricByEntityRefs), - logger: mockServices.logger.mock(), + expect(readAggregatedMetricByEntityRefs).toHaveBeenCalledWith( + ['component:default/a'], + metric.id, + ); + expect(result.id).toBe(metric.id); + expect(result.metadata?.aggregationType).toBe( + aggregationTypes.statusGrouped, + ); }); - const result = await service.getAggregatedMetricByEntityRefs({ - metric, - entityRefs: ['component:default/a'], - thresholds, - aggregationConfig: { - id: 'weightedKpi', - title: 'Weighted health KPI', - description: 'Weighted health score across statuses', - metricId: metric.id, - type: aggregationTypes.weightedStatusScore, - options: { - statusScores: { error: 0, warning: 50, success: 100 }, - thresholds: DEFAULT_WEIGHTED_STATUS_SCORE_KPI_RESULT_THRESHOLDS, - }, - } as AggregationConfig, - } as AggregationOptions); - - expect(readAggregatedMetricByEntityRefs).toHaveBeenCalledWith( - ['component:default/a'], - metric.id, - ); - - const aggregationResult = - result.result as WeightedStatusScoreAggregationResult; - - expect(result.metadata?.aggregationType).toBe( - aggregationTypes.weightedStatusScore, - ); - expect(aggregationResult.weightedStatusScore).toBe(50); - expect(aggregationResult.weightedStatusSum).toBe(150); - expect(aggregationResult.weightedStatusMaxPossible).toBe(300); - }); + it('should use weightedStatusScore strategy when configured', async () => { + const dbRow: DbAggregatedMetric = { + metric_id: metric.id, + total: 3, + max_timestamp: new Date('2025-01-01T10:00:00.000Z'), + statusCounts: { error: 1, warning: 1, success: 1 }, + calculation_error_count: 1, + latest_entity_count: 1, + }; + const readAggregatedMetricByEntityRefs = jest + .fn() + .mockResolvedValue(dbRow); + + const service = new AggregationsService({ + config: mockServices.rootConfig({ data: {} }), + database: createDatabaseMock({ readAggregatedMetricByEntityRefs }), + logger: mockServices.logger.mock(), + }); + + const result = await service.getAggregatedMetricByEntityRefs({ + metric, + entityRefs: ['component:default/a'], + thresholds, + aggregationConfig: mockWeightedStatusScoreAggregationConfig({ + metricId: metric.id, + }), + }); + + expect(readAggregatedMetricByEntityRefs).toHaveBeenCalledWith( + ['component:default/a'], + metric.id, + ); - it('getAggregatedMetricByEntityRefs throws when aggregation type is not registered', async () => { - const service = new AggregationsService({ - config: mockServices.rootConfig({ data: {} }), - database: createDatabaseMock(jest.fn()), - logger: mockServices.logger.mock(), + const aggregationResult = + result.result as WeightedStatusScoreAggregationResult; + + expect(result.metadata?.aggregationType).toBe( + aggregationTypes.weightedStatusScore, + ); + expect(aggregationResult.weightedStatusScore).toBe(50); + expect(aggregationResult.weightedStatusSum).toBe(150); + expect(aggregationResult.weightedStatusMaxPossible).toBe(300); }); - await expect( - service.getAggregatedMetricByEntityRefs({ + it('should use sum strategy when configured', async () => { + const dbRow: DbScalarAggregatedMetric = { + metric_id: metric.id, + value: 847, + total: 42, + latest_entity_count: 45, + calculation_error_count: 3, + max_timestamp: new Date('2025-01-01T10:00:00.000Z'), + }; + const readScalarAggregatedMetricByEntityRefs = jest + .fn() + .mockResolvedValue(dbRow); + + const service = new AggregationsService({ + config: mockServices.rootConfig({ data: {} }), + database: createDatabaseMock({ + readScalarAggregatedMetricByEntityRefs, + }), + logger: mockServices.logger.mock(), + }); + + const result = await service.getAggregatedMetricByEntityRefs({ metric, - entityRefs: [], + entityRefs: ['component:default/a'], thresholds, - aggregationConfig: { - id: metric.id, + aggregationConfig: mockScalarAggregationConfig(aggregationTypes.sum, { + id: 'totalOpenPrs', + title: 'Total Open PRs', + description: 'Sum of open PRs', metricId: metric.id, - type: 'unknownStrategy' as any, - } as any, - }), - ).rejects.toThrow(/Unsupported aggregation type: unknownStrategy/); + options: undefined, + }), + }); + + expect(readScalarAggregatedMetricByEntityRefs).toHaveBeenCalledWith( + ['component:default/a'], + metric.id, + 'sum', + ); + expect(result.metadata?.aggregationType).toBe(aggregationTypes.sum); + expect(result.result).toEqual({ + value: 847, + total: 42, + entitiesConsidered: 45, + calculationErrorCount: 3, + timestamp: '2025-01-01T10:00:00.000Z', + thresholds: DEFAULT_NUMBER_THRESHOLDS, + }); + }); + + it('should throw when aggregation type is not registered', async () => { + const service = new AggregationsService({ + config: mockServices.rootConfig({ data: {} }), + database: createDatabaseMock({}), + logger: mockServices.logger.mock(), + }); + + await expect( + service.getAggregatedMetricByEntityRefs({ + metric, + entityRefs: [], + thresholds, + aggregationConfig: { + id: metric.id, + metricId: metric.id, + type: 'unknownStrategy' as any, + } as any, + }), + ).rejects.toThrow(/Unsupported aggregation type: unknownStrategy/); + }); }); describe('getAggregationConfig', () => { - it('defaults to statusGrouped with metricId equal to aggregation id when KPI config is absent', () => { + it('should default to statusGrouped with metricId equal to aggregation id when KPI config is absent', () => { const logger = mockServices.logger.mock(); const service = new AggregationsService({ config: mockServices.rootConfig({ data: {} }), - database: createDatabaseMock(jest.fn()), + database: createDatabaseMock({}), logger, }); @@ -181,7 +239,7 @@ describe('AggregationsService', () => { ); }); - it('uses scorecard.aggregationKPIs when present', () => { + it('should use scorecard.aggregationKPIs when present', () => { const config = new ConfigReader({ scorecard: { aggregationKPIs: { @@ -200,7 +258,7 @@ describe('AggregationsService', () => { const service = new AggregationsService({ config, - database: createDatabaseMock(jest.fn()), + database: createDatabaseMock({}), logger: mockServices.logger.mock(), }); @@ -208,7 +266,8 @@ describe('AggregationsService', () => { expect(cfg.metricId).toBe('github.open_prs'); expect(cfg.type).toBe(aggregationTypes.weightedStatusScore); - expect(cfg.title).toBe('KPI title'); + expect(isValidatedAggregationConfig(cfg)).toBe(true); + expect((cfg as any).title).toBe('KPI title'); }); }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/ValueAggregationStrategy.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/ValueAggregationStrategy.ts new file mode 100644 index 0000000000..f0f7575795 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/ValueAggregationStrategy.ts @@ -0,0 +1,76 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + DEFAULT_NUMBER_THRESHOLDS, + type AggregatedMetricResult, + type ScalarAggregationResult, +} from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import type { ScalarAggregationFn } from '../../../database/types'; +import { AggregatedMetricMapper } from '../../mappers'; +import type { AggregatedMetricLoader } from '../AggregatedMetricLoader'; +import type { AggregationOptions } from '../types'; +import { isScalarAggregationRuntimeConfig } from '../utils/aggregationRuntimeConfig'; +import type { AggregationStrategy } from './types'; + +export class ValueAggregationStrategy implements AggregationStrategy { + constructor( + private readonly loader: AggregatedMetricLoader, + private readonly aggregationFn: ScalarAggregationFn, + ) {} + + async aggregate( + options: AggregationOptions, + ): Promise { + const { entityRefs, metric, aggregationConfig } = options; + + if (!isScalarAggregationRuntimeConfig(aggregationConfig)) { + throw new Error( + `Expected a validated scalar aggregation config but received type "${aggregationConfig.type}"`, + ); + } + + const { + value, + total, + entitiesConsidered, + calculationErrorCount, + timestamp, + } = await this.loader.loadScalarMetricByEntityRefs( + entityRefs, + metric.id, + this.aggregationFn, + ); + + const thresholds = + aggregationConfig.options?.thresholds ?? DEFAULT_NUMBER_THRESHOLDS; + + const result: ScalarAggregationResult = { + value, + total, + entitiesConsidered, + calculationErrorCount, + timestamp, + thresholds, + }; + + return AggregatedMetricMapper.toAggregatedMetricResult( + metric, + result, + aggregationConfig, + ); + } +} diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/WeightedStatusScoreAggregationStrategy.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/WeightedStatusScoreAggregationStrategy.ts index 3684a549a0..7cb8bbddd3 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/WeightedStatusScoreAggregationStrategy.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/WeightedStatusScoreAggregationStrategy.ts @@ -20,7 +20,7 @@ import { type AggregatedMetricResult, type ThresholdConfig, ThresholdRule, - type AggregationConfigOptions, + aggregationTypes, } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; import { DEFAULT_WEIGHTED_STATUS_SCORE_KPI_RESULT_THRESHOLDS } from '../../../constants/aggregationKPIs'; import { AggregatedMetricMapper } from '../../mappers'; @@ -44,15 +44,15 @@ export class WeightedStatusScoreAggregationStrategy thresholds, aggregationConfig, }: AggregationOptions): Promise { - const { options } = aggregationConfig; - - if (!options?.statusScores) { + if (aggregationConfig.type !== aggregationTypes.weightedStatusScore) { throw new Error( - `The "scorecard.aggregationKPIs.${aggregationConfig.id}.options.statusScores" is required for weightedStatusScore aggregation`, + `Expected aggregation type "${aggregationTypes.weightedStatusScore}" but received "${aggregationConfig.type}"`, ); } - if (!options.thresholds) { + const { statusScores } = aggregationConfig.options; + + if (!aggregationConfig.options.thresholds) { this.logger.info( `The "scorecard.aggregationKPIs.${aggregationConfig.id}.options.thresholds" is not configured for weightedStatusScore aggregation; ` + 'using the default 0–100% health scale (higher is better).', @@ -60,7 +60,8 @@ export class WeightedStatusScoreAggregationStrategy } const headlineThresholds = - options.thresholds ?? DEFAULT_WEIGHTED_STATUS_SCORE_KPI_RESULT_THRESHOLDS; + aggregationConfig.options.thresholds ?? + DEFAULT_WEIGHTED_STATUS_SCORE_KPI_RESULT_THRESHOLDS; const aggregatedMetric = await this.loader.loadStatusGroupedMetricByEntityRefs( @@ -70,14 +71,14 @@ export class WeightedStatusScoreAggregationStrategy const weightedSum = this.calculateWeightedSum( aggregatedMetric.values, - options.statusScores, + statusScores, metric.id, ); const { weightedStatusScore, maxPossibleScore } = this.prepareWeightedStatusScoreValues( aggregatedMetric.total, - options.statusScores, + statusScores, thresholds.rules, weightedSum, ); @@ -101,7 +102,7 @@ export class WeightedStatusScoreAggregationStrategy values: thresholds.rules.map(rule => ({ name: rule.key, count: aggregatedMetric.values[rule.key] ?? 0, - score: options.statusScores[rule.key] ?? 0, + score: statusScores[rule.key] ?? 0, })), thresholds, weightedStatusScore, @@ -119,7 +120,7 @@ export class WeightedStatusScoreAggregationStrategy private calculateWeightedSum( values: Pick['values'], - statusScores: AggregationConfigOptions['statusScores'], + statusScores: Record, metricId: string, ): number { let weightedSum = 0; @@ -153,7 +154,7 @@ export class WeightedStatusScoreAggregationStrategy private prepareWeightedStatusScoreValues( numberOfEntities: Pick['total'], - statusScores: AggregationConfigOptions['statusScores'], + statusScores: Record, rules: ThresholdRule[], weightedSum: number, ): { weightedStatusScore: number; maxPossibleScore: number } { diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/registerStrategies.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/registerStrategies.test.ts index 3766c848d6..cd1717ce18 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/registerStrategies.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/registerStrategies.test.ts @@ -20,9 +20,10 @@ import { AggregatedMetricLoader } from '../AggregatedMetricLoader'; import { createAggregationStrategyRegistry } from './registerStrategies'; import { WeightedStatusScoreAggregationStrategy } from './WeightedStatusScoreAggregationStrategy'; import { StatusGroupedAggregationStrategy } from './StatusGroupedAggregationStrategy'; +import { ValueAggregationStrategy } from './ValueAggregationStrategy'; describe('createAggregationStrategyRegistry', () => { - it('registers statusGrouped and weightedStatusScore strategies', () => { + it('should register all aggregation strategies', () => { const loader = {} as AggregatedMetricLoader; const logger = mockServices.logger.mock(); @@ -34,6 +35,30 @@ describe('createAggregationStrategyRegistry', () => { expect(registry.get(aggregationTypes.weightedStatusScore)).toBeInstanceOf( WeightedStatusScoreAggregationStrategy, ); - expect(registry.size).toBe(2); + expect(registry.get(aggregationTypes.sum)).toBeInstanceOf( + ValueAggregationStrategy, + ); + expect(registry.get(aggregationTypes.average)).toBeInstanceOf( + ValueAggregationStrategy, + ); + expect(registry.get(aggregationTypes.max)).toBeInstanceOf( + ValueAggregationStrategy, + ); + expect(registry.get(aggregationTypes.min)).toBeInstanceOf( + ValueAggregationStrategy, + ); + expect(registry.get(aggregationTypes.count)).toBeInstanceOf( + ValueAggregationStrategy, + ); + expect(registry.get(aggregationTypes.sum)).not.toBe( + registry.get(aggregationTypes.average), + ); + expect(registry.get(aggregationTypes.sum)).not.toBe( + registry.get(aggregationTypes.max), + ); + expect(registry.get(aggregationTypes.average)).not.toBe( + registry.get(aggregationTypes.min), + ); + expect(registry.size).toBe(7); }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/registerStrategies.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/registerStrategies.ts index 6172e05606..01ae0464d9 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/registerStrategies.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/registerStrategies.ts @@ -22,6 +22,7 @@ import type { AggregatedMetricLoader } from '../AggregatedMetricLoader'; import type { AggregationStrategy } from './types'; import { StatusGroupedAggregationStrategy } from './StatusGroupedAggregationStrategy'; import { WeightedStatusScoreAggregationStrategy } from './WeightedStatusScoreAggregationStrategy'; +import { ValueAggregationStrategy } from './ValueAggregationStrategy'; import { LoggerService } from '@backstage/backend-plugin-api'; export function createAggregationStrategyRegistry( @@ -37,5 +38,25 @@ export function createAggregationStrategyRegistry( aggregationTypes.weightedStatusScore, new WeightedStatusScoreAggregationStrategy(loader, logger), ], + [ + aggregationTypes.sum, + new ValueAggregationStrategy(loader, aggregationTypes.sum), + ], + [ + aggregationTypes.average, + new ValueAggregationStrategy(loader, aggregationTypes.average), + ], + [ + aggregationTypes.max, + new ValueAggregationStrategy(loader, aggregationTypes.max), + ], + [ + aggregationTypes.min, + new ValueAggregationStrategy(loader, aggregationTypes.min), + ], + [ + aggregationTypes.count, + new ValueAggregationStrategy(loader, aggregationTypes.count), + ], ]); } diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/statusGroupedAggregationStrategy.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/statusGroupedAggregationStrategy.test.ts index d60eb3a83f..649017cc75 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/statusGroupedAggregationStrategy.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/statusGroupedAggregationStrategy.test.ts @@ -19,6 +19,10 @@ import { Metric, ThresholdConfig, } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import { + mockFallbackStatusGroupedAggregationConfig, + mockStatusGroupedAggregationConfig, +} from '../../../../__fixtures__/mockAggregationConfig'; import { AggregatedMetricLoader } from '../AggregatedMetricLoader'; import { StatusGroupedAggregationStrategy } from './StatusGroupedAggregationStrategy'; import { AggregatedMetricMapper } from '../../mappers'; @@ -75,17 +79,16 @@ describe('StatusGroupedAggregationStrategy', () => { }); const strategy = new StatusGroupedAggregationStrategy(loader); - const aggregationConfig = { + const aggregationConfig = mockStatusGroupedAggregationConfig({ id: metric.id, metricId: metric.id, - type: aggregationTypes.statusGrouped, - } as const; + }); await strategy.aggregate({ metric, entityRefs: ['component:default/a'], thresholds, - aggregationConfig: aggregationConfig as any, + aggregationConfig, }); expect(loadStatusGroupedMetricByEntityRefs).toHaveBeenCalledWith( @@ -120,22 +123,14 @@ describe('StatusGroupedAggregationStrategy', () => { metric, entityRefs: [], thresholds, - aggregationConfig: { + aggregationConfig: mockFallbackStatusGroupedAggregationConfig({ id: metric.id, metricId: metric.id, - type: aggregationTypes.statusGrouped, - } as any, + }), }); expect(result.result.total).toBe(0); expect(result.result.entitiesConsidered).toBe(0); expect(result.result.calculationErrorCount).toBe(0); - expect( - result.result.values.map(v => ({ name: v.name, count: v.count })), - ).toEqual([ - { name: 'error', count: 0 }, - { name: 'warning', count: 0 }, - { name: 'success', count: 0 }, - ]); }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/valueAggregationStrategy.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/valueAggregationStrategy.test.ts new file mode 100644 index 0000000000..f5ea08d4c0 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/valueAggregationStrategy.test.ts @@ -0,0 +1,235 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + aggregationTypes, + DEFAULT_NUMBER_THRESHOLDS, + Metric, + ThresholdConfig, +} from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import { + mockScalarAggregationConfig, + mockStatusGroupedAggregationConfig, +} from '../../../../__fixtures__/mockAggregationConfig'; +import { AggregatedMetricLoader } from '../AggregatedMetricLoader'; +import { ValueAggregationStrategy } from './ValueAggregationStrategy'; +import type { ScalarAggregationFn } from '../../../database/types'; + +describe('ValueAggregationStrategy', () => { + const metric = { + id: 'github.open_prs', + title: 'Open PRs', + description: 'desc', + type: 'number', + } as Metric; + + const providerThresholds: ThresholdConfig = { + rules: [ + { key: 'error', expression: '>40' }, + { key: 'warning', expression: '>20' }, + { key: 'success', expression: '<=20' }, + ], + }; + + it('should load scalar aggregate and maps to API result', async () => { + const loadScalarMetricByEntityRefs = jest.fn().mockResolvedValue({ + value: 847, + total: 42, + entitiesConsidered: 45, + calculationErrorCount: 3, + timestamp: '2025-01-01T10:30:00.000Z', + }); + + const loader = { + loadScalarMetricByEntityRefs, + } as unknown as AggregatedMetricLoader; + + const strategy = new ValueAggregationStrategy(loader, 'sum'); + const aggregationConfig = mockScalarAggregationConfig( + aggregationTypes.sum, + { + id: 'totalOpenPrs', + metricId: metric.id, + options: undefined, + }, + ); + + const result = await strategy.aggregate({ + metric, + entityRefs: ['component:default/a'], + thresholds: providerThresholds, + aggregationConfig, + }); + + expect(loadScalarMetricByEntityRefs).toHaveBeenCalledWith( + ['component:default/a'], + metric.id, + 'sum', + ); + expect(result.metadata.aggregationType).toBe(aggregationTypes.sum); + expect(result.result).toEqual({ + value: 847, + total: 42, + entitiesConsidered: 45, + calculationErrorCount: 3, + timestamp: '2025-01-01T10:30:00.000Z', + thresholds: DEFAULT_NUMBER_THRESHOLDS, + }); + }); + + it('should return zero scalar result for empty entityRefs', async () => { + const loadScalarMetricByEntityRefs = jest.fn().mockResolvedValue({ + value: 0, + total: 0, + entitiesConsidered: 0, + calculationErrorCount: 0, + timestamp: '2025-01-01T00:00:00.000Z', + }); + + const loader = { + loadScalarMetricByEntityRefs, + } as unknown as AggregatedMetricLoader; + + const strategy = new ValueAggregationStrategy(loader, 'average'); + + const result = await strategy.aggregate({ + metric, + entityRefs: [], + thresholds: providerThresholds, + aggregationConfig: mockScalarAggregationConfig(aggregationTypes.average, { + id: metric.id, + metricId: metric.id, + options: undefined, + }), + }); + + expect(loadScalarMetricByEntityRefs).toHaveBeenCalledWith( + [], + metric.id, + 'average', + ); + expect(result.result).toEqual({ + value: 0, + total: 0, + entitiesConsidered: 0, + calculationErrorCount: 0, + timestamp: '2025-01-01T00:00:00.000Z', + thresholds: DEFAULT_NUMBER_THRESHOLDS, + }); + }); + + it('should use options.thresholds from aggregationConfig when provided', async () => { + const kpiThresholds = { + rules: [ + { key: 'success', expression: '>=80', color: 'success.main' }, + { key: 'warning', expression: '10-79', color: 'warning.main' }, + { key: 'error', expression: '<10', color: 'error.main' }, + ], + }; + + const loadScalarMetricByEntityRefs = jest.fn().mockResolvedValue({ + value: 50, + total: 10, + entitiesConsidered: 10, + calculationErrorCount: 0, + timestamp: '2025-01-01T10:30:00.000Z', + }); + + const loader = { + loadScalarMetricByEntityRefs, + } as unknown as AggregatedMetricLoader; + + const strategy = new ValueAggregationStrategy(loader, 'sum'); + + const result = await strategy.aggregate({ + metric, + entityRefs: ['component:default/a'], + thresholds: providerThresholds, + aggregationConfig: mockScalarAggregationConfig(aggregationTypes.sum, { + id: 'totalOpenPrs', + metricId: metric.id, + options: { thresholds: kpiThresholds }, + }), + }); + + expect(result.result).toMatchObject({ + thresholds: kpiThresholds, + }); + }); + + it('should throw when aggregationConfig is not a scalar type', async () => { + const loader = { + loadScalarMetricByEntityRefs: jest.fn(), + } as unknown as AggregatedMetricLoader; + + const strategy = new ValueAggregationStrategy(loader, 'sum'); + + await expect( + strategy.aggregate({ + metric, + entityRefs: ['component:default/a'], + thresholds: providerThresholds, + aggregationConfig: mockStatusGroupedAggregationConfig(), + }), + ).rejects.toThrow(/Expected a validated scalar aggregation config/); + expect(loader.loadScalarMetricByEntityRefs).not.toHaveBeenCalled(); + }); + + it.each([ + ['sum', aggregationTypes.sum], + ['average', aggregationTypes.average], + ['max', aggregationTypes.max], + ['min', aggregationTypes.min], + ['count', aggregationTypes.count], + ] as const)( + 'should pass aggregationFn %s to loader', + async (aggregationFn, aggregationType) => { + const loadScalarMetricByEntityRefs = jest.fn().mockResolvedValue({ + value: 1, + total: 1, + entitiesConsidered: 1, + calculationErrorCount: 0, + timestamp: '2025-01-01T10:30:00.000Z', + }); + + const loader = { + loadScalarMetricByEntityRefs, + } as unknown as AggregatedMetricLoader; + + const strategy = new ValueAggregationStrategy( + loader, + aggregationFn as ScalarAggregationFn, + ); + + await strategy.aggregate({ + metric, + entityRefs: ['component:default/a'], + thresholds: providerThresholds, + aggregationConfig: mockScalarAggregationConfig(aggregationType, { + id: 'kpi', + metricId: metric.id, + options: undefined, + }), + }); + + expect(loadScalarMetricByEntityRefs).toHaveBeenCalledWith( + ['component:default/a'], + metric.id, + aggregationFn, + ); + }, + ); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/weightedStatusScoreAggregationStrategy.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/weightedStatusScoreAggregationStrategy.test.ts index 18009ba06e..452a0303a4 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/weightedStatusScoreAggregationStrategy.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/weightedStatusScoreAggregationStrategy.test.ts @@ -16,11 +16,13 @@ import { mockServices } from '@backstage/backend-test-utils'; import { - aggregationTypes, Metric, ThresholdConfig, } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; -import { DEFAULT_WEIGHTED_STATUS_SCORE_KPI_RESULT_THRESHOLDS } from '../../../constants/aggregationKPIs'; +import { + mockFallbackStatusGroupedAggregationConfig, + mockWeightedStatusScoreAggregationConfig, +} from '../../../../__fixtures__/mockAggregationConfig'; import { AggregatedMetricLoader } from '../AggregatedMetricLoader'; import { WeightedStatusScoreAggregationStrategy } from './WeightedStatusScoreAggregationStrategy'; @@ -55,21 +57,15 @@ describe('WeightedStatusScoreAggregationStrategy', () => { const logger = mockServices.logger.mock(); const strategy = new WeightedStatusScoreAggregationStrategy(loader, logger); - const aggregationConfig = { - id: 'weightedKpi', + const aggregationConfig = mockWeightedStatusScoreAggregationConfig({ metricId: metric.id, - type: aggregationTypes.weightedStatusScore, - options: { - statusScores: { error: 0, warning: 50, success: 100 }, - thresholds: DEFAULT_WEIGHTED_STATUS_SCORE_KPI_RESULT_THRESHOLDS, - }, - } as const; + }); const out = await strategy.aggregate({ metric, entityRefs: ['component:default/a'], thresholds, - aggregationConfig: aggregationConfig as any, + aggregationConfig, }); expect(out.result).toEqual( @@ -107,14 +103,13 @@ describe('WeightedStatusScoreAggregationStrategy', () => { metric, entityRefs: ['component:default/a'], thresholds, - aggregationConfig: { - id: 'weightedKpi', + aggregationConfig: mockWeightedStatusScoreAggregationConfig({ metricId: metric.id, - type: aggregationTypes.weightedStatusScore, options: { statusScores: { error: 0, warning: 50, success: 100 }, + thresholds: undefined, }, - } as any, + }), }); expect(logger.info).toHaveBeenCalledWith( @@ -129,7 +124,7 @@ describe('WeightedStatusScoreAggregationStrategy', () => { ); }); - it('throws when options.statusScores is missing', async () => { + it('throws when aggregation config is not weightedStatusScore', async () => { const loader = { loadStatusGroupedMetricByEntityRefs: jest.fn().mockResolvedValue({ values: { success: 1 }, @@ -148,15 +143,12 @@ describe('WeightedStatusScoreAggregationStrategy', () => { metric, entityRefs: ['component:default/a'], thresholds, - aggregationConfig: { + aggregationConfig: mockFallbackStatusGroupedAggregationConfig({ id: 'weightedKpi', metricId: metric.id, - type: aggregationTypes.weightedStatusScore, - } as any, + }), }), - ).rejects.toThrow( - /statusScores.*required for weightedStatusScore aggregation/, - ); + ).rejects.toThrow(/Expected aggregation type "weightedStatusScore"/); }); it('warns and ignores when loader returns a status not in the metric threshold rules', async () => { @@ -175,21 +167,15 @@ describe('WeightedStatusScoreAggregationStrategy', () => { const logger = mockServices.logger.mock(); const strategy = new WeightedStatusScoreAggregationStrategy(loader, logger); - const aggregationConfig = { - id: 'weightedKpi', + const aggregationConfig = mockWeightedStatusScoreAggregationConfig({ metricId: metric.id, - type: aggregationTypes.weightedStatusScore, - options: { - statusScores: { error: 0, warning: 50, success: 100 }, - thresholds: DEFAULT_WEIGHTED_STATUS_SCORE_KPI_RESULT_THRESHOLDS, - }, - } as const; + }); const out = await strategy.aggregate({ metric, entityRefs: ['component:default/a'], thresholds, - aggregationConfig: aggregationConfig as any, + aggregationConfig, }); expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('orphan')); diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/types.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/types.ts index d4f58499ea..20a57afb60 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/types.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/types.ts @@ -14,15 +14,28 @@ * limitations under the License. */ -import type { - Metric, - ThresholdConfig, - AggregationConfig, +import { + aggregationTypes, + type Metric, + type ThresholdConfig, } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import type { ValidatedAggregationConfig } from '../../validation/schemas/aggregationConfigSchemas'; + +/** Default when no scorecard.aggregationKPIs block exists for the id. */ +export type FallbackStatusGroupedAggregationConfig = { + id: string; + type: typeof aggregationTypes.statusGrouped; + metricId: string; +}; + +/** Config passed into aggregation strategies at request time. */ +export type AggregationRuntimeConfig = + | ValidatedAggregationConfig + | FallbackStatusGroupedAggregationConfig; export type AggregationOptions = { metric: Metric; entityRefs: string[]; thresholds: ThresholdConfig; - aggregationConfig: AggregationConfig; + aggregationConfig: AggregationRuntimeConfig; }; diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/utils/aggregationRuntimeConfig.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/utils/aggregationRuntimeConfig.test.ts new file mode 100644 index 0000000000..ee5e8cecb1 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/utils/aggregationRuntimeConfig.test.ts @@ -0,0 +1,86 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { scalarAggregationTypes } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import { + mockFallbackStatusGroupedAggregationConfig, + mockScalarAggregationConfig, + mockStatusGroupedAggregationConfig, + mockWeightedStatusScoreAggregationConfig, +} from '../../../../__fixtures__/mockAggregationConfig'; +import { + isScalarAggregationRuntimeConfig, + isValidatedAggregationConfig, +} from './aggregationRuntimeConfig'; + +describe('aggregationRuntimeConfig', () => { + describe('isValidatedAggregationConfig', () => { + it('should return true for validated aggregation configs', () => { + expect( + isValidatedAggregationConfig(mockStatusGroupedAggregationConfig()), + ).toBe(true); + expect(isValidatedAggregationConfig(mockScalarAggregationConfig())).toBe( + true, + ); + expect( + isValidatedAggregationConfig( + mockWeightedStatusScoreAggregationConfig(), + ), + ).toBe(true); + }); + + it('should return false for fallback statusGrouped configs', () => { + expect( + isValidatedAggregationConfig( + mockFallbackStatusGroupedAggregationConfig(), + ), + ).toBe(false); + }); + }); + + describe('isScalarAggregationRuntimeConfig', () => { + it.each(scalarAggregationTypes)( + 'should return true for validated scalar type %s', + type => { + expect( + isScalarAggregationRuntimeConfig(mockScalarAggregationConfig(type)), + ).toBe(true); + }, + ); + + it('should return false for statusGrouped configs', () => { + expect( + isScalarAggregationRuntimeConfig(mockStatusGroupedAggregationConfig()), + ).toBe(false); + }); + + it('should return false for weightedStatusScore configs', () => { + expect( + isScalarAggregationRuntimeConfig( + mockWeightedStatusScoreAggregationConfig(), + ), + ).toBe(false); + }); + + it('should return false for fallback statusGrouped configs', () => { + expect( + isScalarAggregationRuntimeConfig( + mockFallbackStatusGroupedAggregationConfig(), + ), + ).toBe(false); + }); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/utils/aggregationRuntimeConfig.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/utils/aggregationRuntimeConfig.ts new file mode 100644 index 0000000000..3ecef63b98 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/utils/aggregationRuntimeConfig.ts @@ -0,0 +1,37 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { scalarAggregationTypes } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import type { + ScalarAggregationConfig, + ValidatedAggregationConfig, +} from '../../../validation/schemas/aggregationConfigSchemas'; +import type { AggregationRuntimeConfig } from '../types'; + +export function isValidatedAggregationConfig( + config: AggregationRuntimeConfig, +): config is ValidatedAggregationConfig { + return 'title' in config; +} + +export function isScalarAggregationRuntimeConfig( + config: AggregationRuntimeConfig, +): config is ScalarAggregationConfig { + return ( + isValidatedAggregationConfig(config) && + (scalarAggregationTypes as readonly string[]).includes(config.type) + ); +} diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/mappers.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/mappers.test.ts index 29b302d1c1..09ee4b4061 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/mappers.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/mappers.test.ts @@ -15,14 +15,20 @@ */ import { AggregatedMetricMapper } from './mappers'; -import { DbAggregatedMetric } from '../database/types'; +import { + DbAggregatedMetric, + DbScalarAggregatedMetric, +} from '../database/types'; import { aggregationTypes, DEFAULT_NUMBER_THRESHOLDS, Metric, ThresholdConfig, - type AggregationConfig, } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import { + mockStatusGroupedAggregationConfig, + mockWeightedStatusScoreAggregationConfig, +} from '../../__fixtures__/mockAggregationConfig'; describe('AggregatedMetricMapper', () => { const mockMetric: Metric = { @@ -93,6 +99,41 @@ describe('AggregatedMetricMapper', () => { }); }); + describe('toScalarAggregatedMetric', () => { + it('should map DbScalarAggregatedMetric to scalar aggregate', () => { + const dbMetric: DbScalarAggregatedMetric = { + metric_id: 'test.metric', + value: 847, + total: 42, + latest_entity_count: 45, + calculation_error_count: 3, + max_timestamp: new Date('2024-01-15T10:00:00Z'), + }; + + const result = AggregatedMetricMapper.toScalarAggregatedMetric(dbMetric); + + expect(result).toEqual({ + value: 847, + total: 42, + entitiesConsidered: 45, + calculationErrorCount: 3, + timestamp: '2024-01-15T10:00:00.000Z', + }); + }); + + it('should handle undefined input with defaults', () => { + const result = AggregatedMetricMapper.toScalarAggregatedMetric(); + + expect(result).toEqual({ + value: 0, + total: 0, + entitiesConsidered: 0, + calculationErrorCount: 0, + timestamp: expect.any(String), + }); + }); + }); + describe('toAggregationMetadata', () => { it('should map to AggregationMetadata when no aggregationConfig is provided', () => { const result = AggregatedMetricMapper.toAggregationMetadata(mockMetric); @@ -107,13 +148,7 @@ describe('AggregatedMetricMapper', () => { }); it('should map to AggregationMetadata when aggregationConfig is provided', () => { - const aggregationConfig: AggregationConfig = { - id: 'test.metric', - type: 'statusGrouped', - title: 'Test Metric', - description: 'Test description', - metricId: 'test.metric', - }; + const aggregationConfig = mockStatusGroupedAggregationConfig(); const result = AggregatedMetricMapper.toAggregationMetadata( mockMetric, aggregationConfig, @@ -133,13 +168,11 @@ describe('AggregatedMetricMapper', () => { const thresholds: ThresholdConfig = DEFAULT_NUMBER_THRESHOLDS; it('should wrap a statusGrouped-shaped result and aggregation metadata from config', () => { - const aggregationConfig: AggregationConfig = { + const aggregationConfig = mockStatusGroupedAggregationConfig({ id: 'kpi-1', - type: 'statusGrouped', title: 'KPI', description: 'KPI desc', - metricId: 'test.metric', - } as AggregationConfig; + }); const result = AggregatedMetricMapper.toAggregatedMetricResult( mockMetric, { @@ -183,13 +216,11 @@ describe('AggregatedMetricMapper', () => { }); it('should wrap a weightedStatusScore-shaped result and aggregationType from config', () => { - const aggregationConfig: AggregationConfig = { + const aggregationConfig = mockWeightedStatusScoreAggregationConfig({ id: 'avg.kpi', - type: aggregationTypes.weightedStatusScore, title: 'Weighted Status Score KPI', description: 'Weighted status score KPI', - metricId: 'test.metric', - } as AggregationConfig; + }); const result = AggregatedMetricMapper.toAggregatedMetricResult( mockMetric, { diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/mappers.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/mappers.ts index 921c65d61d..b189964514 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/mappers.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/mappers.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import type { AggregationRuntimeConfig } from './aggregations/types'; import { AggregatedMetric, AggregatedMetricResult, @@ -21,18 +22,23 @@ import { Metric, aggregationTypes, AggregationResultByType, - type AggregationConfig, + ScalarAggregatedMetric, } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; import { DbAggregatedMetric } from '../database/types'; +import type { DbScalarAggregatedMetric } from '../database/types'; + +function normalizeTimestamp(timestamp?: Date): string { + return timestamp + ? new Date(timestamp).toISOString() + : new Date().toISOString(); +} export class AggregatedMetricMapper { static toAggregatedMetric( aggregatedMetric?: DbAggregatedMetric, ): AggregatedMetric { const total = aggregatedMetric?.total ?? 0; - const timestamp = aggregatedMetric?.max_timestamp - ? new Date(aggregatedMetric.max_timestamp).toISOString() - : new Date().toISOString(); + const timestamp = normalizeTimestamp(aggregatedMetric?.max_timestamp); return { values: aggregatedMetric?.statusCounts ?? {}, @@ -43,24 +49,47 @@ export class AggregatedMetricMapper { }; } + static toScalarAggregatedMetric( + scalarMetric?: DbScalarAggregatedMetric, + ): ScalarAggregatedMetric { + const timestamp = normalizeTimestamp(scalarMetric?.max_timestamp); + + return { + value: scalarMetric?.value ?? 0, + total: scalarMetric?.total ?? 0, + entitiesConsidered: scalarMetric?.latest_entity_count ?? 0, + calculationErrorCount: scalarMetric?.calculation_error_count ?? 0, + timestamp, + }; + } + static toAggregationMetadata( metric: Metric, - aggregationConfig?: AggregationConfig, + aggregationConfig?: AggregationRuntimeConfig, ): AggregationMetadata { + const title = + aggregationConfig && 'title' in aggregationConfig + ? aggregationConfig.title + : metric.title; + const description = + aggregationConfig && 'description' in aggregationConfig + ? aggregationConfig.description + : metric.description; + return { - title: aggregationConfig?.title ?? metric.title, - description: aggregationConfig?.description ?? metric.description, + title, + description, type: metric.type, history: metric.history, aggregationType: - aggregationConfig?.type ?? aggregationTypes.statusGrouped, // By default, return the status grouped aggregation type + aggregationConfig?.type ?? aggregationTypes.statusGrouped, }; } static toAggregatedMetricResult( metric: Metric, result: AggregationResultByType, - aggregationConfig?: AggregationConfig, + aggregationConfig?: AggregationRuntimeConfig, ): AggregatedMetricResult { return { id: metric.id, diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/router.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/router.test.ts index 5338c2b5d7..b8ca0e9400 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/router.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/router.test.ts @@ -34,6 +34,7 @@ import { AggregatedMetric, AggregatedMetricResult, aggregationTypes, + DEFAULT_NUMBER_THRESHOLDS, Metric, MetricResult, } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; @@ -1252,6 +1253,90 @@ describe('createRouter', () => { 'github.open_prs', ); }); + + it('should use KPI type sum and return scalar result', async () => { + const kpiConfig = new ConfigReader({ + scorecard: { + aggregationKPIs: { + totalOpenPrs: { + title: 'Total Open PRs', + description: 'Sum of open PRs', + type: aggregationTypes.sum, + metricId: 'github.open_prs', + }, + }, + }, + }); + const kpiService = new CatalogMetricService({ + catalog: mockCatalog, + auth: mockServices.auth.mock({ + getOwnServiceCredentials: jest.fn().mockResolvedValue({ + token: 'test-token', + }), + }), + registry: metricRegistry, + database: mockDatabaseMetricValues, + logger: mockServices.logger.mock(), + thresholdResolver, + }); + + const getSpy = jest + .spyOn( + mockDatabaseMetricValues, + 'readScalarAggregatedMetricByEntityRefs', + ) + .mockResolvedValue({ + metric_id: 'github.open_prs', + value: 847, + total: 42, + latest_entity_count: 45, + calculation_error_count: 3, + max_timestamp: new Date('2025-01-01T10:30:00.000Z'), + }); + + jest + .spyOn(AggregatedMetricMapper, 'toAggregatedMetricResult') + .mockRestore(); + + const aggregationsServiceSum = createTestAggregationsService( + mockDatabaseMetricValues as unknown as DatabaseMetricValues, + kpiConfig, + ); + + const router = await createRouter({ + metricProvidersRegistry: metricRegistry, + service: { + aggregationsService: aggregationsServiceSum, + catalogMetricService: kpiService, + }, + catalog: mockCatalog, + httpAuth: httpAuthMock, + permissions: permissionsMock, + logger: mockServices.logger.mock(), + thresholdResolver, + }); + const kpiApp = express(); + kpiApp.use(router); + kpiApp.use(mockErrorHandler()); + + const response = await request(kpiApp).get('/aggregations/totalOpenPrs'); + + expect(response.status).toBe(200); + expect(getSpy).toHaveBeenCalledWith( + ['component:default/my-service', 'component:default/my-other-service'], + 'github.open_prs', + 'sum', + ); + expect(response.body.metadata.aggregationType).toBe(aggregationTypes.sum); + expect(response.body.result).toEqual({ + value: 847, + total: 42, + entitiesConsidered: 45, + calculationErrorCount: 3, + timestamp: '2025-01-01T10:30:00.000Z', + thresholds: DEFAULT_NUMBER_THRESHOLDS, + }); + }); }); describe('GET /aggregations/:aggregationId/metadata', () => { diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/utils/buildAggregationConfig.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/utils/buildAggregationConfig.test.ts index fff3fa2921..51b48ae566 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/utils/buildAggregationConfig.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/utils/buildAggregationConfig.test.ts @@ -94,4 +94,73 @@ describe('buildAggregationConfig', () => { { key: 'error', expression: '<10', color: 'error.main' }, ]); }); + + it('should map optional thresholds for scalar KPIs', () => { + const config = new ConfigReader({ + title: 'Total Open PRs', + description: 'Sum of open PRs', + type: aggregationTypes.sum, + metricId: 'github.open_prs', + options: { + thresholds: { + rules: [ + { key: 'success', expression: '>=80', color: 'success.main' }, + ], + }, + }, + }); + + const result = buildAggregationConfig('totalOpenPrsKpi', { config }); + + expect(result.options?.thresholds?.rules).toEqual([ + { key: 'success', expression: '>=80', color: 'success.main' }, + ]); + }); + + it('should not map options for statusGrouped KPIs even when thresholds are configured', () => { + const config = new ConfigReader({ + title: 'Status breakdown', + description: 'Counts by status', + type: aggregationTypes.statusGrouped, + metricId: 'github.open_prs', + options: { + thresholds: { + rules: [ + { key: 'success', expression: '>=80', color: 'success.main' }, + ], + }, + }, + }); + + const result = buildAggregationConfig('statusKpi', { config }); + + expect(result).toEqual({ + id: 'statusKpi', + title: 'Status breakdown', + description: 'Counts by status', + type: aggregationTypes.statusGrouped, + metricId: 'github.open_prs', + }); + expect(result.options).toBeUndefined(); + }); + + it('should map scalar KPI config without options', () => { + const config = new ConfigReader({ + title: 'Total Open PRs', + description: 'Sum of open PRs', + type: aggregationTypes.sum, + metricId: 'github.open_prs', + }); + + const result = buildAggregationConfig('totalOpenPrsKpi', { config }); + + expect(result).toEqual({ + id: 'totalOpenPrsKpi', + title: 'Total Open PRs', + description: 'Sum of open PRs', + type: aggregationTypes.sum, + metricId: 'github.open_prs', + }); + expect(result.options).toBeUndefined(); + }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/utils/buildAggregationConfig.ts b/workspaces/scorecard/plugins/scorecard-backend/src/utils/buildAggregationConfig.ts index ff4e09aa68..07d8a1c39b 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/utils/buildAggregationConfig.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/utils/buildAggregationConfig.ts @@ -21,6 +21,7 @@ import { type AggregationConfigOptions, type AggregationConfig, } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import { isScalarAggregationType } from './isScalarAggregationType'; function buildStatusScores( config: Config, @@ -54,6 +55,14 @@ function buildAggregationThresholdsConfig( return undefined; } +function buildWeightedStatusScoreOptions( + config: Config, +): AggregationConfigOptions | undefined { + const statusScores = buildStatusScores(config); + const thresholds = buildAggregationThresholdsConfig(config); + return { statusScores, ...(thresholds ? { thresholds } : {}) }; +} + export function buildAggregationConfig( aggregationId: string, options: { @@ -71,10 +80,13 @@ export function buildAggregationConfig( } as AggregationConfig; if (aggregationConfig.type === aggregationTypes.weightedStatusScore) { - aggregationConfig.options = { - statusScores: buildStatusScores(config), - thresholds: buildAggregationThresholdsConfig(config), - }; + aggregationConfig.options = buildWeightedStatusScoreOptions(config); + } else if (isScalarAggregationType(aggregationConfig.type)) { + const thresholds = buildAggregationThresholdsConfig(config); + + if (thresholds) { + aggregationConfig.options = { thresholds }; + } } return aggregationConfig; diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/utils/isScalarAggregationType.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/utils/isScalarAggregationType.test.ts new file mode 100644 index 0000000000..993b4d6a28 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend/src/utils/isScalarAggregationType.test.ts @@ -0,0 +1,37 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + aggregationTypes, + scalarAggregationTypes, +} from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import { isScalarAggregationType } from './isScalarAggregationType'; + +describe('isScalarAggregationType', () => { + it.each(scalarAggregationTypes)('returns true for scalar type %s', type => { + expect(isScalarAggregationType(type)).toBe(true); + }); + + it('returns false for statusGrouped', () => { + expect(isScalarAggregationType(aggregationTypes.statusGrouped)).toBe(false); + }); + + it('returns false for weightedStatusScore', () => { + expect(isScalarAggregationType(aggregationTypes.weightedStatusScore)).toBe( + false, + ); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/utils/isScalarAggregationType.ts b/workspaces/scorecard/plugins/scorecard-backend/src/utils/isScalarAggregationType.ts new file mode 100644 index 0000000000..c7c329e8b9 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend/src/utils/isScalarAggregationType.ts @@ -0,0 +1,22 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { scalarAggregationTypes } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import { AggregationType } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +export function isScalarAggregationType(type: AggregationType): boolean { + return (scalarAggregationTypes as readonly string[]).includes(type); +} diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/validation/schemas/aggregationConfigSchemas.ts b/workspaces/scorecard/plugins/scorecard-backend/src/validation/schemas/aggregationConfigSchemas.ts index f8761667b9..8979cb6e15 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/validation/schemas/aggregationConfigSchemas.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/validation/schemas/aggregationConfigSchemas.ts @@ -15,7 +15,20 @@ */ import { z } from 'zod'; -import { aggregationTypes } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import { + aggregationTypes, + scalarAggregationTypes, +} from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +const thresholdsConfigSchema = z.object({ + rules: z.array( + z.object({ + key: z.string(), + expression: z.string(), + color: z.string(), + }), + ), +}); const baseAggregationConfigSchema = z.object({ id: z.string().min(1).max(128), @@ -38,21 +51,50 @@ const weightedStatusScoreAggregationConfigSchema = z.object({ .refine(scores => Object.keys(scores).length > 0, { message: 'options.statusScores must contain at least one weight value', }), - thresholds: z - .object({ - rules: z.array( - z.object({ - key: z.string(), - expression: z.string(), - color: z.string(), - }), - ), - }) - .optional(), + thresholds: thresholdsConfigSchema.optional(), }), }); +function scalarAggregationConfigSchema( + type: (typeof scalarAggregationTypes)[number], +) { + return z.object({ + ...baseAggregationConfigSchema.shape, + type: z.literal(type), + options: z + .strictObject({ + thresholds: thresholdsConfigSchema.optional(), + }) + .optional(), + }); +} + export const aggregationConfigSchema = z.discriminatedUnion('type', [ statusGroupedAggregationConfigSchema, weightedStatusScoreAggregationConfigSchema, + scalarAggregationConfigSchema(aggregationTypes.sum), + scalarAggregationConfigSchema(aggregationTypes.average), + scalarAggregationConfigSchema(aggregationTypes.max), + scalarAggregationConfigSchema(aggregationTypes.min), + scalarAggregationConfigSchema(aggregationTypes.count), ]); + +/** Post-validation aggregation KPI config (Zod discriminated union). */ +export type ValidatedAggregationConfig = z.infer< + typeof aggregationConfigSchema +>; + +export type WeightedStatusScoreAggregationConfig = Extract< + ValidatedAggregationConfig, + { type: typeof aggregationTypes.weightedStatusScore } +>; + +export type ScalarAggregationConfig = Extract< + ValidatedAggregationConfig, + { type: (typeof scalarAggregationTypes)[number] } +>; + +export type StatusGroupedAggregationConfig = Extract< + ValidatedAggregationConfig, + { type: typeof aggregationTypes.statusGrouped } +>; diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/validation/validateAggregationConfig.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/validation/validateAggregationConfig.test.ts index 158afa5548..cbcdeea555 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/validation/validateAggregationConfig.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/validation/validateAggregationConfig.test.ts @@ -19,7 +19,10 @@ import { InputError } from '@backstage/errors'; import { aggregationTypes } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; import { validateAggregationConfig } from './validateAggregationConfig'; import { MetricProvidersRegistry } from '../providers/MetricProvidersRegistry'; -import { MockNumberProvider } from '../../__fixtures__/mockProviders'; +import { + MockBooleanProvider, + MockNumberProvider, +} from '../../__fixtures__/mockProviders'; import { AGGREGATION_KPIS_CONFIG_PATH } from '../constants'; describe('validateAggregationConfig', () => { @@ -313,4 +316,144 @@ describe('validateAggregationConfig', () => { /do not cover the entire real line/, ); }); + + it('should not throw when scalar sum KPI is valid for a number metric', () => { + const registry = new MetricProvidersRegistry(); + registry.register(new MockNumberProvider('github.open_prs', 'github')); + + const rootConfig = new ConfigReader({ + scorecard: { + aggregationKPIs: { + totalOpenPrs: { + title: 'Total Open PRs', + description: 'Sum of open PRs', + type: aggregationTypes.sum, + metricId: 'github.open_prs', + }, + }, + }, + }); + + expect(() => + validateAggregationConfig({ rootConfig, registry }), + ).not.toThrow(); + }); + + it('should throw InputError when sum KPI targets a boolean metric', () => { + const registry = new MetricProvidersRegistry(); + registry.register(new MockBooleanProvider('jira.license', 'jira')); + + const rootConfig = new ConfigReader({ + scorecard: { + aggregationKPIs: { + badSumKpi: { + title: 'Bad sum', + description: 'Sum on boolean metric', + type: aggregationTypes.sum, + metricId: 'jira.license', + }, + }, + }, + }); + + expect(() => validateAggregationConfig({ rootConfig, registry })).toThrow( + InputError, + ); + expect(() => validateAggregationConfig({ rootConfig, registry })).toThrow( + /requires a number metric/, + ); + }); + + it('should throw when count KPI targets a boolean metric', () => { + const registry = new MetricProvidersRegistry(); + registry.register(new MockBooleanProvider('jira.license', 'jira')); + + const rootConfig = new ConfigReader({ + scorecard: { + aggregationKPIs: { + licenseCount: { + title: 'License count', + description: 'Count entities with license metric', + type: aggregationTypes.count, + metricId: 'jira.license', + }, + }, + }, + }); + + expect(() => validateAggregationConfig({ rootConfig, registry })).toThrow( + InputError, + ); + expect(() => validateAggregationConfig({ rootConfig, registry })).toThrow( + /requires a number metric/, + ); + }); + + it.each([ + aggregationTypes.average, + aggregationTypes.max, + aggregationTypes.min, + aggregationTypes.count, + ])( + 'should not throw when scalar %s KPI is valid for a number metric', + type => { + const registry = new MetricProvidersRegistry(); + registry.register(new MockNumberProvider('github.open_prs', 'github')); + + const rootConfig = new ConfigReader({ + scorecard: { + aggregationKPIs: { + scalarKpi: { + title: 'Scalar KPI', + description: 'Scalar aggregation', + type, + metricId: 'github.open_prs', + }, + }, + }, + }); + + expect(() => + validateAggregationConfig({ rootConfig, registry }), + ).not.toThrow(); + }, + ); + + it('should throw when scalar KPI thresholds leave a gap on the real line', () => { + const registry = new MetricProvidersRegistry(); + registry.register(new MockNumberProvider('github.open_prs', 'github')); + + const rootConfig = new ConfigReader({ + scorecard: { + aggregationKPIs: { + badScalarKpi: { + title: 'Bad scalar', + description: 'Scalar with gap thresholds', + type: aggregationTypes.sum, + metricId: 'github.open_prs', + options: { + thresholds: { + rules: [ + { + key: 'success', + expression: '<10', + color: 'success.main', + }, + { + key: 'error', + expression: '>20', + color: 'error.main', + }, + ], + }, + }, + }, + }, + }, + }); + + expect(() => validateAggregationConfig({ rootConfig, registry })).toThrow( + /do not cover the entire real line/, + ); + }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/validation/validateAggregationConfig.ts b/workspaces/scorecard/plugins/scorecard-backend/src/validation/validateAggregationConfig.ts index 7665228786..6c1c495dcf 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/validation/validateAggregationConfig.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/validation/validateAggregationConfig.ts @@ -19,14 +19,39 @@ import type { Config } from '@backstage/config'; import { MetricProvidersRegistry } from '../providers/MetricProvidersRegistry'; import { AGGREGATION_KPIS_CONFIG_PATH } from '../constants'; import { aggregationConfigSchema } from './schemas/aggregationConfigSchemas'; +import type { ValidatedAggregationConfig } from './schemas/aggregationConfigSchemas'; import { buildAggregationConfig } from '../utils/buildAggregationConfig'; import { validateThresholdsForAggregation } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; import { - type AggregationConfig, aggregationTypes, + scalarAggregationTypes, } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; -function parseAggregationConfig(config: unknown): AggregationConfig { +function isScalarAggregationType(type: string): boolean { + return (scalarAggregationTypes as readonly string[]).includes(type); +} + +function validateScalarAggregationConfig( + aggregationConfig: ValidatedAggregationConfig, + registry: MetricProvidersRegistry, + aggregationId: string, +): void { + if (!isScalarAggregationType(aggregationConfig.type)) { + return; + } + + const metric = registry.getMetric(aggregationConfig.metricId); + + if (metric.type === 'boolean') { + throw new InputError( + `Aggregation KPI "${aggregationId}" uses type "${aggregationConfig.type}" which requires a number metric, but "${aggregationConfig.metricId}" is boolean.`, + ); + } +} + +export function parseValidatedAggregationConfig( + config: unknown, +): ValidatedAggregationConfig { const parsed = aggregationConfigSchema.safeParse(config); if (!parsed.success) { @@ -38,7 +63,7 @@ function parseAggregationConfig(config: unknown): AggregationConfig { } if ( - parsed.data?.type === aggregationTypes.weightedStatusScore && + parsed.data.type !== aggregationTypes.statusGrouped && parsed.data.options?.thresholds ) { validateThresholdsForAggregation(parsed.data.options.thresholds, 'number'); @@ -64,16 +89,18 @@ export function validateAggregationConfig(options: { for (const aggregationId of aggregationKPIsConfig.keys()) { const config = aggregationKPIsConfig.getConfig(aggregationId); - const aggregationConfig = buildAggregationConfig(aggregationId, { - config, - }); - - parseAggregationConfig(aggregationConfig); + const aggregationConfig = parseValidatedAggregationConfig( + buildAggregationConfig(aggregationId, { + config, + }), + ); if (!registry.hasProvider(aggregationConfig.metricId)) { throw new Error( `Metric provider with ID '${aggregationConfig.metricId}' is not registered (${AGGREGATION_KPIS_CONFIG_PATH}.${aggregationId}).`, ); } + + validateScalarAggregationConfig(aggregationConfig, registry, aggregationId); } } diff --git a/workspaces/scorecard/plugins/scorecard-common/report.api.md b/workspaces/scorecard/plugins/scorecard-common/report.api.md index c34eb8680a..957059e518 100644 --- a/workspaces/scorecard/plugins/scorecard-common/report.api.md +++ b/workspaces/scorecard/plugins/scorecard-common/report.api.md @@ -41,7 +41,7 @@ export type AggregationConfig = { // @public (undocumented) export type AggregationConfigOptions = { - statusScores: Record; + statusScores?: Record; thresholds?: ThresholdConfig; }; @@ -57,7 +57,8 @@ export type AggregationMetadata = { // @public (undocumented) export type AggregationResultByType = | StatusGroupedAggregationResult - | WeightedStatusScoreAggregationResult; + | WeightedStatusScoreAggregationResult + | ScalarAggregationResult; // @public export type AggregationThresholdRule = Pick< @@ -73,6 +74,11 @@ export type AggregationType = export const aggregationTypes: Readonly<{ statusGrouped: 'statusGrouped'; weightedStatusScore: 'weightedStatusScore'; + sum: 'sum'; + average: 'average'; + max: 'max'; + min: 'min'; + count: 'count'; }>; // @public @@ -149,6 +155,30 @@ export type MetricValue = T extends 'number' // @public (undocumented) export const RESOURCE_TYPE_SCORECARD_METRIC = 'scorecard-metric'; +// @public (undocumented) +export type ScalarAggregatedMetric = { + value: number; + total: number; + timestamp: string; + entitiesConsidered: number; + calculationErrorCount: number; +}; + +// @public (undocumented) +export type ScalarAggregationResult = Omit & { + value: number; + thresholds: ThresholdConfig; +}; + +// @public +export const scalarAggregationTypes: readonly [ + 'sum', + 'average', + 'max', + 'min', + 'count', +]; + // @public export const SCORECARD_THRESHOLD_RULE_COLOR_VALUES: ( | 'success.main' diff --git a/workspaces/scorecard/plugins/scorecard-common/src/constants/aggregations.ts b/workspaces/scorecard/plugins/scorecard-common/src/constants/aggregations.ts index 99be2b042b..b76b27461a 100644 --- a/workspaces/scorecard/plugins/scorecard-common/src/constants/aggregations.ts +++ b/workspaces/scorecard/plugins/scorecard-common/src/constants/aggregations.ts @@ -16,6 +16,11 @@ const STATUS_GROUPED = 'statusGrouped' as const; const WEIGHTED_STATUS_SCORE = 'weightedStatusScore' as const; +const SUM = 'sum' as const; +const AVERAGE = 'average' as const; +const MAX = 'max' as const; +const MIN = 'min' as const; +const COUNT = 'count' as const; /** * Supported aggregation types @@ -24,4 +29,21 @@ const WEIGHTED_STATUS_SCORE = 'weightedStatusScore' as const; export const aggregationTypes = Object.freeze({ statusGrouped: STATUS_GROUPED, weightedStatusScore: WEIGHTED_STATUS_SCORE, + sum: SUM, + average: AVERAGE, + max: MAX, + min: MIN, + count: COUNT, }); + +/** + * Scalar aggregation types that operate on raw metric values. + * @public + */ +export const scalarAggregationTypes = Object.freeze([ + SUM, + AVERAGE, + MAX, + MIN, + COUNT, +] as const); diff --git a/workspaces/scorecard/plugins/scorecard-common/src/types/aggregation.ts b/workspaces/scorecard/plugins/scorecard-common/src/types/aggregation.ts index a44efb9c11..3e14c7fefa 100644 --- a/workspaces/scorecard/plugins/scorecard-common/src/types/aggregation.ts +++ b/workspaces/scorecard/plugins/scorecard-common/src/types/aggregation.ts @@ -54,6 +54,25 @@ export type AggregatedMetric = { calculationErrorCount: number; }; +/** + * @public + */ +export type ScalarAggregatedMetric = { + value: number; + total: number; + timestamp: string; + /** + * Entities in aggregation scope that have at least one latest stored `metric_values` row for this metric + * (aligned with the drill-down list total when the same ownership filters apply). + */ + entitiesConsidered: number; + /** + * How many of those entities have a latest stored row that is a metric **calculation** failure + * (`error_message` set and `value` null), distinct from threshold status counts in `values` / `total`. + */ + calculationErrorCount: number; +}; + /** * @public */ @@ -78,12 +97,21 @@ export type WeightedStatusScoreAggregationResult = aggregationChartDisplayColor: string; }; +/** + * @public + */ +export type ScalarAggregationResult = Omit & { + value: number; + thresholds: ThresholdConfig; +}; + /** * @public */ export type AggregationResultByType = | StatusGroupedAggregationResult - | WeightedStatusScoreAggregationResult; + | WeightedStatusScoreAggregationResult + | ScalarAggregationResult; /** * @public @@ -99,7 +127,7 @@ export type AggregatedMetricResult = { * @public */ export type AggregationConfigOptions = { - statusScores: Record; + statusScores?: Record; thresholds?: ThresholdConfig; }; diff --git a/workspaces/scorecard/plugins/scorecard/README.md b/workspaces/scorecard/plugins/scorecard/README.md index 887635a0e9..89bc9fd8e5 100644 --- a/workspaces/scorecard/plugins/scorecard/README.md +++ b/workspaces/scorecard/plugins/scorecard/README.md @@ -6,7 +6,7 @@ The plugin supports both the **legacy** Backstage frontend and the **New Fronten **Features:** - **Entity scorecard tab** — View scorecard metrics on catalog entity pages (components, websites, etc.). -- **Scorecard homepage card** — Show aggregated KPIs on the home page (e.g. GitHub open PRs, Jira open issues). Supports **`statusGrouped`** (multi-slice pie) and **`weightedStatusScore`** (weighted health donut) KPI types configured under **`scorecard.aggregationKPIs`**. +- **Scorecard homepage card** — Show aggregated KPIs on the home page (e.g. GitHub open PRs, Jira open issues). The built-in card renders **`statusGrouped`** (multi-slice pie), **`weightedStatusScore`** (weighted health donut), and scalar types (`sum`, `average`, `max`, `min`, `count`). - **Scorecard Entities page** — Drill down from an aggregated metric to see the list of entities contributing to that metric, with entity-level values and status, so you can identify services impacting the KPI and investigate issues. ## Getting started