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/.changeset/stupid-knives-wonder.md b/workspaces/scorecard/.changeset/stupid-knives-wonder.md new file mode 100644 index 0000000000..2a6fda19d5 --- /dev/null +++ b/workspaces/scorecard/.changeset/stupid-knives-wonder.md @@ -0,0 +1,18 @@ +--- +'@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 +--- + +**BREAKING**: Rename aggregation KPI type `average` to `weightedStatusScore`. + +### 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..dc55dc824b 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`, 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.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..f52c2488f9 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: @@ -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/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..4d31bf8809 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 **`weightedStatusScore`** and **scalar** KPI types, 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 @@ -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 `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), `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 **`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`**, 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 a88ff66541..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 `average` */ - 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. average percentage 0–100 for `average` 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 945d8050d1..018f320b57 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/docs/aggregation.md +++ b/workspaces/scorecard/plugins/scorecard-backend/docs/aggregation.md @@ -34,26 +34,61 @@ 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). - -| 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. | +**`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 **`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`**, 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: 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`** 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`**). @@ -63,10 +98,16 @@ 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`**. 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`**. -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. +**`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 **`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 **`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 **`average`** 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), **average 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 d7d9fc1433..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: 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 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 fb93aac7a6..4fbb03dd61 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/docs/thresholds.md +++ b/workspaces/scorecard/plugins/scorecard-backend/docs/thresholds.md @@ -152,19 +152,33 @@ 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`**). + +### 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 @@ -429,6 +443,6 @@ rules: ## Related documentation -- [Entity Aggregation](./aggregation.md) — ownership, **`GET /aggregations/:aggregationId`**, **`statusGrouped`** vs **`average`** +- [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/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/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 38fd55c34f..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 @@ -18,22 +18,33 @@ import { ConfigReader } from '@backstage/config'; import { mockServices } from '@backstage/backend-test-utils'; import { aggregationTypes, - type AggregatedMetricAverageResult, + type WeightedStatusScoreAggregationResult, Metric, ThresholdConfig, - type AggregationConfig, + DEFAULT_NUMBER_THRESHOLDS, } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; -import { DEFAULT_AVERAGE_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,118 +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 average 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: 'avgKpi', - title: 'Average KPI', - description: 'Average KPI description', - metricId: metric.id, - type: aggregationTypes.average, - options: { - statusScores: { error: 0, warning: 50, success: 100 }, - thresholds: DEFAULT_AVERAGE_KPI_RESULT_THRESHOLDS, - }, - } as AggregationConfig, - } as AggregationOptions); + 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); - expect(readAggregatedMetricByEntityRefs).toHaveBeenCalledWith( - ['component:default/a'], - metric.id, - ); + const service = new AggregationsService({ + config: mockServices.rootConfig({ data: {} }), + database: createDatabaseMock({ readAggregatedMetricByEntityRefs }), + logger: mockServices.logger.mock(), + }); - const aggregationResult = result.result as AggregatedMetricAverageResult; + const result = await service.getAggregatedMetricByEntityRefs({ + metric, + entityRefs: ['component:default/a'], + thresholds, + aggregationConfig: mockWeightedStatusScoreAggregationConfig({ + metricId: metric.id, + }), + }); - expect(result.metadata?.aggregationType).toBe(aggregationTypes.average); - expect(aggregationResult.averageScore).toBe(50); - expect(aggregationResult.averageWeightedSum).toBe(150); - expect(aggregationResult.averageMaxPossible).toBe(300); - }); + expect(readAggregatedMetricByEntityRefs).toHaveBeenCalledWith( + ['component:default/a'], + metric.id, + ); + + const aggregationResult = + result.result as WeightedStatusScoreAggregationResult; - 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(), + 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, }); @@ -178,14 +239,14 @@ describe('AggregationsService', () => { ); }); - it('uses scorecard.aggregationKPIs when present', () => { + it('should use scorecard.aggregationKPIs when present', () => { const config = new ConfigReader({ scorecard: { aggregationKPIs: { 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 }, @@ -197,15 +258,16 @@ describe('AggregationsService', () => { const service = new AggregationsService({ config, - database: createDatabaseMock(jest.fn()), + database: createDatabaseMock({}), logger: mockServices.logger.mock(), }); const cfg = service.getAggregationConfig('myKpi'); expect(cfg.metricId).toBe('github.open_prs'); - expect(cfg.type).toBe(aggregationTypes.average); - expect(cfg.title).toBe('KPI title'); + expect(cfg.type).toBe(aggregationTypes.weightedStatusScore); + 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/AverageAggregationStrategy.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/WeightedStatusScoreAggregationStrategy.ts similarity index 69% 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..7cb8bbddd3 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, + aggregationTypes, } 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, @@ -42,23 +44,24 @@ export class AverageAggregationStrategy implements AggregationStrategy { 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 average 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 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; + aggregationConfig.options.thresholds ?? + DEFAULT_WEIGHTED_STATUS_SCORE_KPI_RESULT_THRESHOLDS; const aggregatedMetric = await this.loader.loadStatusGroupedMetricByEntityRefs( @@ -68,25 +71,26 @@ export class AverageAggregationStrategy implements AggregationStrategy { const weightedSum = this.calculateWeightedSum( aggregatedMetric.values, - options.statusScores, + statusScores, metric.id, ); - const { averageScore, maxPossibleScore } = this.prepareScoreValues( - aggregatedMetric.total, - options.statusScores, - thresholds.rules, - weightedSum, - ); + const { weightedStatusScore, maxPossibleScore } = + this.prepareWeightedStatusScoreValues( + aggregatedMetric.total, + 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.`, ); } @@ -98,14 +102,14 @@ export class AverageAggregationStrategy implements AggregationStrategy { 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, - averageScore, - averageWeightedSum: weightedSum, - averageMaxPossible: maxPossibleScore, + weightedStatusScore, + weightedStatusSum: weightedSum, + weightedStatusMaxPossible: maxPossibleScore, aggregationChartDisplayColor, - } as AggregatedMetricAverageResult; + } as WeightedStatusScoreAggregationResult; return AggregatedMetricMapper.toAggregatedMetricResult( metric, @@ -116,7 +120,7 @@ export class AverageAggregationStrategy implements AggregationStrategy { private calculateWeightedSum( values: Pick['values'], - statusScores: AggregationConfigOptions['statusScores'], + statusScores: Record, metricId: string, ): number { let weightedSum = 0; @@ -125,7 +129,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 +152,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'], + statusScores: Record, 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..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 @@ -18,11 +18,12 @@ 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'; +import { ValueAggregationStrategy } from './ValueAggregationStrategy'; describe('createAggregationStrategyRegistry', () => { - it('registers statusGrouped and average strategies', () => { + it('should register all aggregation strategies', () => { const loader = {} as AggregatedMetricLoader; const logger = mockServices.logger.mock(); @@ -31,9 +32,33 @@ describe('createAggregationStrategyRegistry', () => { expect(registry.get(aggregationTypes.statusGrouped)).toBeInstanceOf( StatusGroupedAggregationStrategy, ); + expect(registry.get(aggregationTypes.weightedStatusScore)).toBeInstanceOf( + WeightedStatusScoreAggregationStrategy, + ); + expect(registry.get(aggregationTypes.sum)).toBeInstanceOf( + ValueAggregationStrategy, + ); expect(registry.get(aggregationTypes.average)).toBeInstanceOf( - AverageAggregationStrategy, + 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(2); + 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 8cb7c6db37..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 @@ -21,7 +21,8 @@ import { import type { AggregatedMetricLoader } from '../AggregatedMetricLoader'; import type { AggregationStrategy } from './types'; import { StatusGroupedAggregationStrategy } from './StatusGroupedAggregationStrategy'; -import { AverageAggregationStrategy } from './AverageAggregationStrategy'; +import { WeightedStatusScoreAggregationStrategy } from './WeightedStatusScoreAggregationStrategy'; +import { ValueAggregationStrategy } from './ValueAggregationStrategy'; import { LoggerService } from '@backstage/backend-plugin-api'; export function createAggregationStrategyRegistry( @@ -33,6 +34,29 @@ export function createAggregationStrategyRegistry( aggregationTypes.statusGrouped, new StatusGroupedAggregationStrategy(loader), ], - [aggregationTypes.average, new AverageAggregationStrategy(loader, logger)], + [ + 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/averageAggregationStrategy.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/weightedStatusScoreAggregationStrategy.test.ts similarity index 71% 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..452a0303a4 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 @@ -16,15 +16,17 @@ import { mockServices } from '@backstage/backend-test-utils'; import { - aggregationTypes, Metric, ThresholdConfig, } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; -import { DEFAULT_AVERAGE_KPI_RESULT_THRESHOLDS } from '../../../constants/aggregationKPIs'; +import { + mockFallbackStatusGroupedAggregationConfig, + mockWeightedStatusScoreAggregationConfig, +} from '../../../../__fixtures__/mockAggregationConfig'; 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 +42,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,22 +56,16 @@ describe('AverageAggregationStrategy', () => { } as unknown as AggregatedMetricLoader; const logger = mockServices.logger.mock(); - const strategy = new AverageAggregationStrategy(loader, logger); - const aggregationConfig = { - id: 'avgKpi', + const strategy = new WeightedStatusScoreAggregationStrategy(loader, logger); + const aggregationConfig = mockWeightedStatusScoreAggregationConfig({ metricId: metric.id, - type: aggregationTypes.average, - options: { - statusScores: { error: 0, warning: 50, success: 100 }, - thresholds: DEFAULT_AVERAGE_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( @@ -77,9 +73,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,25 +97,24 @@ 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, entityRefs: ['component:default/a'], thresholds, - aggregationConfig: { - id: 'avgKpi', + aggregationConfig: mockWeightedStatusScoreAggregationConfig({ metricId: metric.id, - type: aggregationTypes.average, options: { statusScores: { error: 0, warning: 50, success: 100 }, + thresholds: undefined, }, - } as any, + }), }); 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( @@ -129,7 +124,7 @@ describe('AverageAggregationStrategy', () => { ); }); - 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 }, @@ -141,20 +136,19 @@ 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({ metric, entityRefs: ['component:default/a'], thresholds, - aggregationConfig: { - id: 'avgKpi', + aggregationConfig: mockFallbackStatusGroupedAggregationConfig({ + id: 'weightedKpi', metricId: metric.id, - type: aggregationTypes.average, - } as any, + }), }), - ).rejects.toThrow(/statusScores.*required for average aggregation/); + ).rejects.toThrow(/Expected aggregation type "weightedStatusScore"/); }); it('warns and ignores when loader returns a status not in the metric threshold rules', async () => { @@ -171,23 +165,17 @@ 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', + const aggregationConfig = mockWeightedStatusScoreAggregationConfig({ metricId: metric.id, - type: aggregationTypes.average, - options: { - statusScores: { error: 0, warning: 50, success: 100 }, - thresholds: DEFAULT_AVERAGE_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')); @@ -195,9 +183,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/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 5d0a6ef941..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, { @@ -182,14 +215,12 @@ describe('AggregatedMetricMapper', () => { }); }); - it('should wrap a average-shaped result and aggregationType from config', () => { - const aggregationConfig: AggregationConfig = { + it('should wrap a weightedStatusScore-shaped result and aggregationType from config', () => { + const aggregationConfig = mockWeightedStatusScoreAggregationConfig({ id: 'avg.kpi', - type: aggregationTypes.average, - title: 'Avg KPI', - description: 'Average KPI', - metricId: 'test.metric', - } as AggregationConfig; + title: 'Weighted Status Score KPI', + description: 'Weighted status score KPI', + }); const result = AggregatedMetricMapper.toAggregatedMetricResult( mockMetric, { @@ -201,16 +232,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/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 3c2750b8e3..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'; @@ -1187,14 +1188,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: { + weightedKpi: { title: 'Weighted health KPI', - description: 'Weighted average', - type: 'average', + description: 'Weighted status score', + type: 'weightedStatusScore', metricId: 'github.open_prs', options: { statusScores: { @@ -1245,13 +1246,97 @@ 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'], '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 5b977cb335..51b48ae566 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: { @@ -53,13 +53,13 @@ describe('buildAggregationConfig', () => { }, }); - const result = buildAggregationConfig('avgKpi', { config }); + const result = buildAggregationConfig('weightedKpi', { config }); expect(result).toEqual({ - id: 'avgKpi', + id: 'weightedKpi', 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 }, @@ -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' }, @@ -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 1cad8142a8..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: { @@ -70,11 +79,14 @@ export function buildAggregationConfig( description: config.getString('description'), } as AggregationConfig; - if (aggregationConfig.type === aggregationTypes.average) { - aggregationConfig.options = { - statusScores: buildStatusScores(config), - thresholds: buildAggregationThresholdsConfig(config), - }; + if (aggregationConfig.type === aggregationTypes.weightedStatusScore) { + 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 20eec7d0c3..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), @@ -29,30 +42,59 @@ 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()) .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, - averageAggregationConfigSchema, + 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 951c39d3a1..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', () => { @@ -122,7 +125,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 +134,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,17 +154,17 @@ 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')); const rootConfig = new ConfigReader({ scorecard: { aggregationKPIs: { - avgKpi: { + weightedKpi: { title: 'Avg KPI', - type: aggregationTypes.average, - description: 'Weighted health', + type: aggregationTypes.weightedStatusScore, + description: 'Weighted health score', metricId: 'github.open_prs', }, }, @@ -173,17 +176,17 @@ 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')); const rootConfig = new ConfigReader({ scorecard: { aggregationKPIs: { - avgKpi: { + weightedKpi: { title: 'Avg KPI', - type: aggregationTypes.average, - description: 'Weighted health', + type: aggregationTypes.weightedStatusScore, + description: 'Weighted health score', metricId: 'github.open_prs', options: { statusScores: {} }, }, @@ -196,17 +199,17 @@ 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')); const rootConfig = new ConfigReader({ scorecard: { aggregationKPIs: { - avgKpi: { + weightedKpi: { 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 }, @@ -243,10 +246,10 @@ describe('validateAggregationConfig', () => { const rootConfig = new ConfigReader({ scorecard: { aggregationKPIs: { - avgKpi: { + weightedKpi: { 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,17 +273,17 @@ 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')); const rootConfig = new ConfigReader({ scorecard: { aggregationKPIs: { - avgKpi: { + weightedKpi: { 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 }, @@ -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 1aa223e987..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.average && + 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 213f62c1bd..957059e518 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; @@ -49,7 +41,7 @@ export type AggregationConfig = { // @public (undocumented) export type AggregationConfigOptions = { - statusScores: Record; + statusScores?: Record; thresholds?: ThresholdConfig; }; @@ -65,7 +57,8 @@ export type AggregationMetadata = { // @public (undocumented) export type AggregationResultByType = | StatusGroupedAggregationResult - | AggregatedMetricAverageResult; + | WeightedStatusScoreAggregationResult + | ScalarAggregationResult; // @public export type AggregationThresholdRule = Pick< @@ -80,7 +73,12 @@ export type AggregationType = // @public export const aggregationTypes: Readonly<{ statusGrouped: 'statusGrouped'; + weightedStatusScore: 'weightedStatusScore'; + sum: 'sum'; average: 'average'; + max: 'max'; + min: 'min'; + count: 'count'; }>; // @public @@ -157,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' @@ -219,5 +241,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..b76b27461a 100644 --- a/workspaces/scorecard/plugins/scorecard-common/src/constants/aggregations.ts +++ b/workspaces/scorecard/plugins/scorecard-common/src/constants/aggregations.ts @@ -15,7 +15,12 @@ */ 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 @@ -23,5 +28,22 @@ const AVERAGE = 'average' 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 df1f26a3dc..3e14c7fefa 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; }; @@ -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 */ @@ -70,11 +89,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 ScalarAggregationResult = Omit & { + value: number; + thresholds: ThresholdConfig; }; /** @@ -82,7 +110,8 @@ export type AggregatedMetricAverageResult = StatusGroupedAggregationResult & { */ export type AggregationResultByType = | StatusGroupedAggregationResult - | AggregatedMetricAverageResult; + | WeightedStatusScoreAggregationResult + | ScalarAggregationResult; /** * @public @@ -98,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 5356935f61..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 **`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). 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 @@ -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..abd6bb2e6d 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/AggregatedMetricCard.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/AggregatedMetricCard.tsx @@ -14,35 +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 { AverageCardComponent } from './AverageCard/AverageCardComponent'; -import type { AverageCardComponentProps } from './AverageCard/types'; -import type { StatusGroupedCardComponentProps } from './StatusGroupedCard/types'; +import { WeightedStatusScoreCardComponent } from './WeightedStatusScoreCard/WeightedStatusScoreCardComponent'; import { UnsupportedAggregationType } from './UnsupportedAggregationType'; -export type AggregatedMetricCardProps = - | StatusGroupedCardComponentProps - | AverageCardComponentProps; +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.average: - 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/AverageCard/DonutChartTooltipContent.tsx b/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/WeightedStatusScoreCard/DonutChartTooltipContent.tsx similarity index 91% rename from workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/AverageCard/DonutChartTooltipContent.tsx rename to workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/WeightedStatusScoreCard/DonutChartTooltipContent.tsx index 20429d96d0..05cf41fe27 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/AverageCard/DonutChartTooltipContent.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/WeightedStatusScoreCard/DonutChartTooltipContent.tsx @@ -38,11 +38,11 @@ export const DonutChartTooltipContent = ({ @@ -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/WeightedStatusScoreCard/types.ts b/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/WeightedStatusScoreCard/types.ts new file mode 100644 index 0000000000..1ad8dcdb59 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/WeightedStatusScoreCard/types.ts @@ -0,0 +1,35 @@ +/* + * 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 { + WeightedStatusScoreAggregationResult, + AggregatedMetricResult, + AggregationMetadata, + aggregationTypes, +} from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import { AggregatedMetricCardBaseProps } from '../types'; + +export type WeightedStatusScoreCardComponentProps = + AggregatedMetricCardBaseProps & { + scorecard: Omit & { + metadata: AggregationMetadata & { + aggregationType: typeof aggregationTypes.weightedStatusScore; + }; + result: WeightedStatusScoreAggregationResult; + }; + }; + +export type TooltipPosition = { left: number; top: number }; 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/AggregatedMetricCards/AverageCard/types.ts b/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/types.ts similarity index 68% rename from workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/AverageCard/types.ts rename to workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/types.ts index 48169ffe8c..b8a6a210a3 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/AverageCard/types.ts +++ b/workspaces/scorecard/plugins/scorecard/src/components/AggregatedMetricCards/types.ts @@ -14,15 +14,7 @@ * limitations under the License. */ -import { - AggregatedMetricAverageResult, - AggregatedMetricResult, -} from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; - -export type AverageCardComponentProps = { - scorecard: Omit & { - result: AggregatedMetricAverageResult; - }; +export type AggregatedMetricCardBaseProps = { cardTitle: string; description: string; aggregationId: string; @@ -30,5 +22,3 @@ export type AverageCardComponentProps = { showInfo?: boolean; dataTestId?: string; }; - -export type TooltipPosition = { left: number; top: number }; 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',