From d52e93164957786966986984aab7b45dffaf03b5 Mon Sep 17 00:00:00 2001 From: Eswaraiahsapram Date: Fri, 4 Sep 2026 01:04:26 +0530 Subject: [PATCH 1/3] feat(scorecard): add entity-page sparkline charts for time-series metrics --- .../plugins/scorecard/report-alpha.api.md | 5 + .../plugins/scorecard/report-legacy.api.md | 5 + .../src/api/ScorecardApiClient.test.ts | 253 ++++++++++ .../plugins/scorecard/src/api/index.ts | 186 ++++++++ .../plugins/scorecard/src/api/types.ts | 44 ++ .../MetricGroupCard/DataSourcesDialog.tsx | 170 +++---- .../DataSourcesDialogColumns.tsx | 64 ++- .../MetricGroupCard/MetricGroupCard.tsx | 10 +- .../__tests__/DataSourcesDialog.test.tsx | 209 +++++---- .../DataSourcesDialogColumns.test.tsx | 20 + .../__tests__/MetricGroupCard.test.tsx | 8 + .../__tests__/collectorSourceRows.test.ts | 93 ++++ .../__tests__/metricSourceRows.test.ts | 157 +++++++ .../MetricGroupCard/collectorSourceRows.ts | 64 +++ .../MetricGroupCard/metricSourceRows.ts | 86 ++++ .../components/Scorecard/EntityMetricCard.tsx | 82 ++++ .../Scorecard/EntityScorecardContent.tsx | 54 +-- .../Scorecard/EntitySparklineCard.tsx | 237 ++++++++++ .../ScorecardEntityContentGridView.tsx | 55 +-- .../__tests__/EntityScorecardContent.test.tsx | 78 ++++ .../__tests__/EntitySparklineCard.test.tsx | 442 ++++++++++++++++++ .../ScorecardEntityContentGridView.test.tsx | 89 ++++ .../SparklineChart/SparklineChart.tsx | 271 +++++++++++ .../SparklineChart/SparklineLegend.tsx | 74 +++ .../SparklineChart/SparklineTooltip.tsx | 76 +++ .../__tests__/SparklineChart.test.tsx | 105 +++++ .../__tests__/SparklineTooltip.test.tsx | 80 ++++ .../src/components/SparklineChart/index.ts | 21 + .../__tests__/useMetricCollectors.test.tsx | 152 ++++++ .../__tests__/useMetricTimeSeries.test.tsx | 188 ++++++++ .../src/hooks/useMetricCollectors.tsx | 45 ++ .../src/hooks/useMetricTimeSeries.tsx | 87 ++++ .../plugins/scorecard/src/translations/de.ts | 6 + .../plugins/scorecard/src/translations/es.ts | 6 + .../plugins/scorecard/src/translations/fr.ts | 6 + .../plugins/scorecard/src/translations/it.ts | 6 + .../plugins/scorecard/src/translations/ja.ts | 6 + .../plugins/scorecard/src/translations/ref.ts | 6 + .../__tests__/metricVisualization.test.ts | 28 ++ .../__tests__/sparklineChartModel.test.ts | 76 +++ .../utils/__tests__/sparklineLegend.test.ts | 72 +++ .../__tests__/timeSeriesChartData.test.ts | 208 +++++++++ .../utils/__tests__/timeSeriesRange.test.ts | 31 ++ .../utils/__tests__/translationUtils.test.ts | 22 +- .../plugins/scorecard/src/utils/constants.ts | 3 + .../plugins/scorecard/src/utils/index.ts | 24 + .../src/utils/metricVisualization.ts | 27 ++ .../src/utils/sparklineChartModel.ts | 73 +++ .../scorecard/src/utils/sparklineLegend.ts | 103 ++++ .../src/utils/timeSeriesChartData.ts | 168 +++++++ .../scorecard/src/utils/timeSeriesRange.ts | 33 ++ .../scorecard/src/utils/translationUtils.ts | 2 +- 52 files changed, 4101 insertions(+), 315 deletions(-) create mode 100644 workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/__tests__/collectorSourceRows.test.ts create mode 100644 workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/__tests__/metricSourceRows.test.ts create mode 100644 workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/collectorSourceRows.ts create mode 100644 workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/metricSourceRows.ts create mode 100644 workspaces/scorecard/plugins/scorecard/src/components/Scorecard/EntityMetricCard.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/components/Scorecard/EntitySparklineCard.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/components/Scorecard/__tests__/EntitySparklineCard.test.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/components/SparklineChart/SparklineChart.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/components/SparklineChart/SparklineLegend.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/components/SparklineChart/SparklineTooltip.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/components/SparklineChart/__tests__/SparklineChart.test.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/components/SparklineChart/__tests__/SparklineTooltip.test.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/components/SparklineChart/index.ts create mode 100644 workspaces/scorecard/plugins/scorecard/src/hooks/__tests__/useMetricCollectors.test.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/hooks/__tests__/useMetricTimeSeries.test.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/hooks/useMetricCollectors.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/hooks/useMetricTimeSeries.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/utils/__tests__/metricVisualization.test.ts create mode 100644 workspaces/scorecard/plugins/scorecard/src/utils/__tests__/sparklineChartModel.test.ts create mode 100644 workspaces/scorecard/plugins/scorecard/src/utils/__tests__/sparklineLegend.test.ts create mode 100644 workspaces/scorecard/plugins/scorecard/src/utils/__tests__/timeSeriesChartData.test.ts create mode 100644 workspaces/scorecard/plugins/scorecard/src/utils/__tests__/timeSeriesRange.test.ts create mode 100644 workspaces/scorecard/plugins/scorecard/src/utils/metricVisualization.ts create mode 100644 workspaces/scorecard/plugins/scorecard/src/utils/sparklineChartModel.ts create mode 100644 workspaces/scorecard/plugins/scorecard/src/utils/sparklineLegend.ts create mode 100644 workspaces/scorecard/plugins/scorecard/src/utils/timeSeriesChartData.ts create mode 100644 workspaces/scorecard/plugins/scorecard/src/utils/timeSeriesRange.ts diff --git a/workspaces/scorecard/plugins/scorecard/report-alpha.api.md b/workspaces/scorecard/plugins/scorecard/report-alpha.api.md index 839cad15750..6b65c444545 100644 --- a/workspaces/scorecard/plugins/scorecard/report-alpha.api.md +++ b/workspaces/scorecard/plugins/scorecard/report-alpha.api.md @@ -110,6 +110,11 @@ export const scorecardTranslationRef: TranslationRef< readonly 'dataSourcesDialog.close': string; readonly 'dataSourcesDialog.unknownPlugin': string; readonly 'dataSourcesDialog.statusTooltip': string; + readonly 'dataSourcesDialog.collectorStatusTooltip': string; + readonly 'dataSourcesDialog.collectorEmptyValue': string; + readonly 'dataSourcesDialog.collectorUnavailableStatus': string; + readonly 'dataSourcesDialog.pluginGithub': string; + readonly 'dataSourcesDialog.pluginJira': string; readonly 'dataSourcesDialog.columns.plugin': string; readonly 'dataSourcesDialog.columns.check': string; readonly 'dataSourcesDialog.columns.value': string; diff --git a/workspaces/scorecard/plugins/scorecard/report-legacy.api.md b/workspaces/scorecard/plugins/scorecard/report-legacy.api.md index 1320223d96e..ad03afb5865 100644 --- a/workspaces/scorecard/plugins/scorecard/report-legacy.api.md +++ b/workspaces/scorecard/plugins/scorecard/report-legacy.api.md @@ -136,6 +136,11 @@ export const scorecardTranslationRef: TranslationRef< readonly 'dataSourcesDialog.close': string; readonly 'dataSourcesDialog.unknownPlugin': string; readonly 'dataSourcesDialog.statusTooltip': string; + readonly 'dataSourcesDialog.collectorStatusTooltip': string; + readonly 'dataSourcesDialog.collectorEmptyValue': string; + readonly 'dataSourcesDialog.collectorUnavailableStatus': string; + readonly 'dataSourcesDialog.pluginGithub': string; + readonly 'dataSourcesDialog.pluginJira': string; readonly 'dataSourcesDialog.columns.plugin': string; readonly 'dataSourcesDialog.columns.check': string; readonly 'dataSourcesDialog.columns.value': string; diff --git a/workspaces/scorecard/plugins/scorecard/src/api/ScorecardApiClient.test.ts b/workspaces/scorecard/plugins/scorecard/src/api/ScorecardApiClient.test.ts index 827dc5d3fbe..49e26674b68 100644 --- a/workspaces/scorecard/plugins/scorecard/src/api/ScorecardApiClient.test.ts +++ b/workspaces/scorecard/plugins/scorecard/src/api/ScorecardApiClient.test.ts @@ -307,4 +307,257 @@ describe('ScorecardApiClient', () => { ); }); }); + + describe('getAggregationTimeSeries', () => { + const validTimeSeries = { + id: 'avgDeploymentFrequency', + metricId: 'dora.deploymentFrequency', + points: [ + { + value: 10, + successCount: 5, + errorCount: 0, + total: 5, + status: 'success', + timestamp: '2026-08-23T00:00:00.000Z', + }, + ], + metadata: { + title: 'Average Deployment Frequency', + description: 'Average weekly production deploys', + type: 'number', + history: true, + visualization: 'sparkline', + aggregationType: 'average', + }, + thresholds: { rules: [] }, + aggregationChartDisplayColor: 'warning.main', + }; + + const range = { + from: '2026-07-24T00:00:00.000Z', + to: '2026-08-23T00:00:00.000Z', + }; + + it('should build the time-series URL from aggregationId and range', async () => { + fetchApi.fetch.mockResolvedValue({ + ok: true, + json: async () => validTimeSeries, + }); + + const result = await client.getAggregationTimeSeries({ + aggregationId: 'avgDeploymentFrequency', + ...range, + }); + + expect(fetchApi.fetch).toHaveBeenCalledWith( + 'http://localhost:7007/api/scorecard/aggregations/avgDeploymentFrequency/time-series?from=2026-07-24T00%3A00%3A00.000Z&to=2026-08-23T00%3A00%3A00.000Z', + ); + expect(result).toEqual(validTimeSeries); + }); + + it('should throw when aggregationId is empty', async () => { + await expect( + client.getAggregationTimeSeries({ + aggregationId: '', + ...range, + }), + ).rejects.toThrow( + 'Aggregation ID is required for aggregation time-series lookup', + ); + expect(fetchApi.fetch).not.toHaveBeenCalled(); + }); + + it('should throw when from or to is missing', async () => { + await expect( + client.getAggregationTimeSeries({ + aggregationId: 'avgDeploymentFrequency', + from: '', + to: range.to, + }), + ).rejects.toThrow( + 'from and to are required for aggregation time-series lookup', + ); + expect(fetchApi.fetch).not.toHaveBeenCalled(); + }); + + it('should throw when the response is not a time-series object', async () => { + fetchApi.fetch.mockResolvedValue({ + ok: true, + json: async () => [], + }); + + await expect( + client.getAggregationTimeSeries({ + aggregationId: 'avgDeploymentFrequency', + ...range, + }), + ).rejects.toThrow( + 'Invalid response format from aggregation time-series API', + ); + }); + + it('should throw on non-OK response', async () => { + fetchApi.fetch.mockResolvedValue({ + ok: false, + status: 400, + statusText: 'Bad Request', + text: async () => 'not scalar', + }); + + await expect( + client.getAggregationTimeSeries({ + aggregationId: 'openPrsKpi', + ...range, + }), + ).rejects.toThrow( + 'Failed to fetch aggregation time series: 400 Bad Request. not scalar', + ); + }); + }); + + describe('getMetricTimeSeries', () => { + const validTimeSeries = { + metricId: 'dora.deploymentFrequency', + entityRef: 'component:default/svc-a', + points: [{ value: 8, timestamp: '2026-04-27T23:10:00.000Z' }], + metadata: { + title: 'Deployment Frequency', + description: 'How often we deploy', + type: 'number', + history: true, + defaultVisualization: 'sparkline', + }, + }; + + const range = { + from: '2026-03-31T00:00:00.000Z', + to: '2026-04-30T00:00:00.000Z', + }; + + it('should build the time-series URL from entity, metricId, and range', async () => { + fetchApi.fetch.mockResolvedValue({ + ok: true, + json: async () => validTimeSeries, + }); + + const result = await client.getMetricTimeSeries({ + entity, + metricId: 'dora.deploymentFrequency', + ...range, + }); + + expect(fetchApi.fetch).toHaveBeenCalledWith( + 'http://localhost:7007/api/scorecard/metrics/catalog/Component/default/svc-a/time-series?metricId=dora.deploymentFrequency&from=2026-03-31T00%3A00%3A00.000Z&to=2026-04-30T00%3A00%3A00.000Z', + ); + expect(result).toEqual(validTimeSeries); + }); + + it('should throw when metricId is empty', async () => { + await expect( + client.getMetricTimeSeries({ + entity, + metricId: '', + ...range, + }), + ).rejects.toThrow('Metric ID is required for time-series lookup'); + expect(fetchApi.fetch).not.toHaveBeenCalled(); + }); + + it('should throw when the response is not a time-series object', async () => { + fetchApi.fetch.mockResolvedValue({ + ok: true, + json: async () => [], + }); + + await expect( + client.getMetricTimeSeries({ + entity, + metricId: 'dora.deploymentFrequency', + ...range, + }), + ).rejects.toThrow('Invalid response format from metric time-series API'); + }); + + it('should throw on non-OK response', async () => { + fetchApi.fetch.mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + text: async () => 'nope', + }); + + await expect( + client.getMetricTimeSeries({ + entity, + metricId: 'dora.deploymentFrequency', + ...range, + }), + ).rejects.toThrow( + 'Failed to fetch metric time series: 500 Internal Server Error. nope', + ); + }); + }); + + describe('getMetricCollectors', () => { + const collectorsResponse = { + collectors: [ + { + id: 'github:deploymentWorkflowRuns', + description: 'Collects deployments from GitHub Actions.', + }, + { + id: 'jira:incidents', + description: 'Collects Jira incidents.', + }, + ], + }; + + it('should request collectors for the metric id', async () => { + fetchApi.fetch.mockResolvedValue({ + ok: true, + json: async () => collectorsResponse, + }); + + const result = await client.getMetricCollectors('dora.changeFailureRate'); + + expect(fetchApi.fetch).toHaveBeenCalledWith( + 'http://localhost:7007/api/scorecard/metrics/dora.changeFailureRate/collectors', + ); + expect(result).toEqual(collectorsResponse.collectors); + }); + + it('should throw when metric id is empty', async () => { + await expect(client.getMetricCollectors('')).rejects.toThrow( + 'Metric ID is required for collectors lookup', + ); + expect(fetchApi.fetch).not.toHaveBeenCalled(); + }); + + it('should throw when the response is not a collectors object', async () => { + fetchApi.fetch.mockResolvedValue({ + ok: true, + json: async () => [], + }); + + await expect( + client.getMetricCollectors('dora.changeFailureRate'), + ).rejects.toThrow('Invalid response format from metric collectors API'); + }); + + it('should throw on non-OK response', async () => { + fetchApi.fetch.mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + text: async () => 'nope', + }); + + await expect( + client.getMetricCollectors('dora.changeFailureRate'), + ).rejects.toThrow( + 'Failed to fetch metric collectors: 500 Internal Server Error. nope', + ); + }); + }); }); diff --git a/workspaces/scorecard/plugins/scorecard/src/api/index.ts b/workspaces/scorecard/plugins/scorecard/src/api/index.ts index 023ed4a0425..7ba4c91677b 100644 --- a/workspaces/scorecard/plugins/scorecard/src/api/index.ts +++ b/workspaces/scorecard/plugins/scorecard/src/api/index.ts @@ -25,6 +25,9 @@ import type { AggregationMetadata, Metric, EntityMetricDetailResponse, + MetricTimeSeriesResponse, + AggregatedMetricTimeSeriesResponse, + CollectorMetadata, } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; import type { GetAggregatedScorecardEntitiesOptions } from '../components/types'; @@ -32,6 +35,8 @@ import type { GetAggregatedScorecardEntitiesOptions } from '../components/types' export { ScorecardQueryProvider } from './ScorecardQueryProvider'; import type { + GetAggregationTimeSeriesOptions, + GetMetricTimeSeriesOptions, ScorecardApi, ScorecardApiClientOptions, ScorecardOptions, @@ -364,4 +369,185 @@ export class ScorecardApiClient implements ScorecardApi { ); } } + + async getAggregationTimeSeries({ + aggregationId, + from, + to, + }: GetAggregationTimeSeriesOptions): Promise { + if (!aggregationId || aggregationId.trim() === '') { + throw new Error( + 'Aggregation ID is required for aggregation time-series lookup', + ); + } + + if (!from || !to) { + throw new Error( + 'from and to are required for aggregation time-series lookup', + ); + } + + const baseUrl = await this.getBaseUrl(); + const url = new URL(`${baseUrl}/aggregations/${aggregationId}/time-series`); + url.searchParams.set('from', from); + url.searchParams.set('to', to); + + try { + const response = await this.fetchApi.fetch(url.toString()); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Failed to fetch aggregation time series: ${response.status} ${response.statusText}. ${errorText}`, + ); + } + + const data = await response.json(); + + if ( + !data || + Array.isArray(data) || + typeof data !== 'object' || + typeof data.id !== 'string' || + typeof data.metricId !== 'string' || + !Array.isArray(data.points) + ) { + throw new TypeError( + 'Invalid response format from aggregation time-series API', + ); + } + + return data as AggregatedMetricTimeSeriesResponse; + } catch (error) { + if (error instanceof Error) { + throw error; + } + throw new Error( + `Unexpected error fetching aggregation time series: ${String(error)}`, + ); + } + } + + async getMetricTimeSeries({ + entity, + metricId, + from, + to, + }: GetMetricTimeSeriesOptions): Promise { + if ( + !entity?.kind || + !entity?.metadata?.namespace || + !entity?.metadata?.name + ) { + throw new Error( + 'Entity missing required properties for scorecard lookup', + ); + } + + if (!metricId || metricId.trim() === '') { + throw new Error('Metric ID is required for time-series lookup'); + } + + if (!from || !to) { + throw new Error('from and to are required for time-series lookup'); + } + + const baseUrl = await this.getBaseUrl(); + const url = new URL( + `${baseUrl}/metrics/catalog/${entity.kind}/${entity.metadata.namespace}/${entity.metadata.name}/time-series`, + ); + url.searchParams.set('metricId', metricId); + url.searchParams.set('from', from); + url.searchParams.set('to', to); + + try { + const response = await this.fetchApi.fetch(url.toString()); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Failed to fetch metric time series: ${response.status} ${response.statusText}. ${errorText}`, + ); + } + + const data = await response.json(); + + if ( + !data || + Array.isArray(data) || + typeof data !== 'object' || + typeof data.metricId !== 'string' || + typeof data.entityRef !== 'string' || + !Array.isArray(data.points) + ) { + throw new TypeError( + 'Invalid response format from metric time-series API', + ); + } + + return data as MetricTimeSeriesResponse; + } catch (error) { + if (error instanceof Error) { + throw error; + } + throw new Error( + `Unexpected error fetching metric time series: ${String(error)}`, + ); + } + } + + async getMetricCollectors(metricId: string): Promise { + if (!metricId || metricId.trim() === '') { + throw new Error('Metric ID is required for collectors lookup'); + } + + const baseUrl = await this.getBaseUrl(); + const url = `${baseUrl}/metrics/${encodeURIComponent(metricId)}/collectors`; + + try { + const response = await this.fetchApi.fetch(url); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Failed to fetch metric collectors: ${response.status} ${response.statusText}. ${errorText}`, + ); + } + + const data = await response.json(); + + if ( + !data || + Array.isArray(data) || + typeof data !== 'object' || + !Array.isArray(data.collectors) || + !data.collectors.every(isCollectorMetadata) + ) { + throw new TypeError( + 'Invalid response format from metric collectors API', + ); + } + + return data.collectors; + } catch (error) { + if (error instanceof Error) { + throw error; + } + throw new Error( + `Unexpected error fetching metric collectors: ${String(error)}`, + ); + } + } +} + +function isCollectorMetadata(value: unknown): value is CollectorMetadata { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + + const collector = value as Record; + return ( + typeof collector.id === 'string' && + typeof collector.description === 'string' + ); } diff --git a/workspaces/scorecard/plugins/scorecard/src/api/types.ts b/workspaces/scorecard/plugins/scorecard/src/api/types.ts index 0e90e729c85..48b8bd0dd32 100644 --- a/workspaces/scorecard/plugins/scorecard/src/api/types.ts +++ b/workspaces/scorecard/plugins/scorecard/src/api/types.ts @@ -22,6 +22,9 @@ import { AggregationMetadata, Metric, EntityMetricDetailResponse, + MetricTimeSeriesResponse, + AggregatedMetricTimeSeriesResponse, + CollectorMetadata, } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; import { GetAggregatedScorecardEntitiesOptions } from '../components/types'; @@ -35,6 +38,19 @@ export type ScorecardOptions = { metricIds?: string[]; }; +export type GetMetricTimeSeriesOptions = { + entity: Entity; + metricId: string; + from: string; + to: string; +}; + +export type GetAggregationTimeSeriesOptions = { + aggregationId: string; + from: string; + to: string; +}; + export interface ScorecardApi { /** * Gets the base URL for the scorecard backend API. @@ -86,4 +102,32 @@ export interface ScorecardApi { * @throws Error if the request fails or returns invalid data */ getAggregationMetadata(aggregationId: string): Promise; + + /** + * Retrieves a daily scalar aggregation time series for a KPI or metric id. + * @param options - Aggregation ID and inclusive ISO-8601 range + * @returns Promise resolving to daily aggregated points and KPI metadata + * @throws Error if the request fails or returns invalid data + */ + getAggregationTimeSeries( + options: GetAggregationTimeSeriesOptions, + ): Promise; + + /** + * Retrieves a daily time series for one metric on a catalog entity. + * @param options - Entity, metric ID, and inclusive ISO-8601 range + * @returns Promise resolving to time-series points and metric metadata + * @throws Error if the request fails or returns invalid data + */ + getMetricTimeSeries( + options: GetMetricTimeSeriesOptions, + ): Promise; + + /** + * Retrieves collector metadata for a composite metric. + * @param metricId - Metric ID whose collectors should be listed + * @returns Promise resolving to collector id and description entries + * @throws Error if the request fails or returns invalid data + */ + getMetricCollectors(metricId: string): Promise; } diff --git a/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/DataSourcesDialog.tsx b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/DataSourcesDialog.tsx index cf3aff047f7..282392552d2 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/DataSourcesDialog.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/DataSourcesDialog.tsx @@ -16,7 +16,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import type { MetricResult } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import { ResponseErrorPanel } from '@backstage/core-components'; import type { SortDescriptor } from '@backstage/ui'; import { Dialog, @@ -30,34 +30,27 @@ import { import Box from '@mui/material/Box'; import { useTranslation } from '../../hooks/useTranslation'; -import { useLanguage } from '../../hooks/useLanguage'; -import { - getStatusConfig, - getLastUpdatedLabel, - extractPluginName, - resolveMetricTranslation, -} from '../../utils'; -import { - buildThresholdBuckets, - getMetricBucketKey, - getMetricBucketLabel, - hasMetricEvaluation, - MISSING_EVALUATION_LABEL, -} from './thresholdBucketUtils'; +import { CardLoading } from '../Common/CardLoading'; import { buildColumnConfig, - formatMetricValue, sortSourceRows, type SourceRow, } from './DataSourcesDialogColumns'; import { ThresholdLegend } from './ThresholdLegend'; +import type { ThresholdBucket } from './types'; + +export type { SourceRow }; -interface DataSourcesDialogProps { +export interface DataSourcesDialogProps { open: boolean; onClose: () => void; title: string; - metrics: MetricResult[]; + rows: SourceRow[]; + isLoading?: boolean; + error?: Error; initialFilters?: string[]; + /** When provided, the footer renders a filterable threshold legend. */ + buckets?: ThresholdBucket[]; } /** Scopes dialog style overrides to this instance (set on BUI ModalOverlay). */ @@ -67,63 +60,13 @@ export const DataSourcesDialog = ({ open, onClose, title, - metrics, + rows, + isLoading = false, + error, initialFilters, + buckets, }: DataSourcesDialogProps) => { const { t } = useTranslation(); - const locale = useLanguage(); - - const buckets = useMemo( - () => buildThresholdBuckets(metrics, t), - [metrics, t], - ); - - const rows = useMemo( - () => - metrics.map((metric, index) => { - const evaluationKey = getMetricBucketKey(metric); - const evaluated = hasMetricEvaluation(metric); - const thresholdRules = - metric.result?.thresholdResult?.definition?.rules ?? []; - - const statusConfig = getStatusConfig({ - evaluation: evaluated ? evaluationKey : null, - thresholdStatus: metric.result?.thresholdResult?.status, - metricStatus: metric.status, - thresholdRules, - }); - - const matchedRule = evaluated - ? thresholdRules.find(r => r.key === evaluationKey) - : undefined; - - return { - id: String(index), - plugin: extractPluginName( - metric.id, - t('dataSourcesDialog.unknownPlugin'), - ), - metricId: metric.id, - metricDescription: resolveMetricTranslation( - t, - metric.id, - 'description', - metric.metadata.description, - ), - value: formatMetricValue(metric.result), - evaluationKey, - statusLabel: getMetricBucketLabel(evaluationKey, t), - statusIcon: evaluated ? statusConfig.icon ?? '' : '', - statusColor: statusConfig.color, - lastSynced: metric.result?.timestamp - ? getLastUpdatedLabel(metric.result.timestamp, locale) - : MISSING_EVALUATION_LABEL, - thresholdExpression: matchedRule?.expression ?? null, - unit: metric.metadata.unit, - }; - }), - [metrics, t, locale], - ); const [activeFilters, setActiveFilters] = useState>(new Set()); const [sortDescriptor, setSortDescriptor] = useState( @@ -165,48 +108,59 @@ export const DataSourcesDialog = ({ sortFn: sortSourceRows, }); + const renderBody = () => { + if (isLoading) { + return ; + } + if (error) { + return ; + } + return ( + + + + ); + }; + return ( - <> - !isOpen && onClose()} - width={900} - {...{ [DATA_SOURCES_DIALOG_ATTR]: '' }} + !isOpen && onClose()} + width={900} + {...{ [DATA_SOURCES_DIALOG_ATTR]: '' }} + > + + {t('dataSourcesDialog.title', { title } as any)} + + {renderBody()} + - - {t('dataSourcesDialog.title', { title } as any)} - - - -
- - - + {buckets && ( - - - - + )} + + + ); }; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/DataSourcesDialogColumns.tsx b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/DataSourcesDialogColumns.tsx index 128a5c5531f..b5ea1cc4c1f 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/DataSourcesDialogColumns.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/DataSourcesDialogColumns.tsx @@ -44,6 +44,7 @@ export interface SourceRow extends TableItem { lastSynced: string; thresholdExpression: string | null; unit?: string; + isCollector?: boolean; } const HEADER_STYLE = { @@ -126,21 +127,45 @@ const StatusCell = ({ tooltipText: string; }) => ( - - - - - {item.statusLabel} - - + + + + + + {item.statusLabel} + + + ); +function getStatusTooltip( + item: SourceRow, + t: ReturnType['t'], +): string { + if (item.isCollector) { + return t('dataSourcesDialog.collectorStatusTooltip'); + } + if (!item.thresholdExpression || !item.evaluationKey) { + return ''; + } + return t('dataSourcesDialog.statusTooltip', { + value: item.value, + status: item.statusLabel, + expression: formatWithMetricUnit(item.thresholdExpression, item.unit), + } as any); +} + export function sortSourceRows( data: SourceRow[], sort: SortDescriptor, @@ -226,20 +251,9 @@ export function buildColumnConfig( width={'1fr' as ColumnConfig['width']} /> ), - cell: item => { - const tooltipText = - item.thresholdExpression && item.evaluationKey - ? t('dataSourcesDialog.statusTooltip', { - value: item.value, - status: item.statusLabel, - expression: formatWithMetricUnit( - item.thresholdExpression, - item.unit, - ), - } as any) - : ''; - return ; - }, + cell: item => ( + + ), isSortable: true, width: '1fr' as ColumnConfig['width'], }, diff --git a/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/MetricGroupCard.tsx b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/MetricGroupCard.tsx index 6382a2bb0f8..c01bd38413e 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/MetricGroupCard.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/MetricGroupCard.tsx @@ -19,6 +19,7 @@ import { useState, useCallback, useMemo } from 'react'; import Box from '@mui/material/Box'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; +import { useLanguage } from '../../hooks/useLanguage'; import { useTranslation } from '../../hooks/useTranslation'; import { buildThresholdBuckets, @@ -28,6 +29,7 @@ import { ThresholdBucketTile } from './ThresholdBucketTile'; import { MetricGroupCardMenu } from './MetricGroupCardMenu'; import type { MenuAction } from './MetricGroupCardMenu'; import { DataSourcesDialog } from './DataSourcesDialog'; +import { toMetricSourceRows } from './metricSourceRows'; import type { MetricGroupCardProps } from './types'; import { CardWrapper } from '../Common/CardWrapper'; @@ -39,6 +41,7 @@ export const MetricGroupCard = ({ metrics, }: MetricGroupCardProps) => { const { t } = useTranslation(); + const locale = useLanguage(); const [dataSourcesOpen, setDataSourcesOpen] = useState(false); const [initialFilters, setInitialFilters] = useState([]); const uniqueMetrics = useMemo(() => dedupeMetricsById(metrics), [metrics]); @@ -46,6 +49,10 @@ export const MetricGroupCard = ({ () => buildThresholdBuckets(uniqueMetrics, t), [uniqueMetrics, t], ); + const sourceRows = useMemo( + () => toMetricSourceRows(uniqueMetrics, { t, locale }), + [uniqueMetrics, t, locale], + ); const handleOpenDataSources = useCallback(() => { setInitialFilters([]); @@ -113,7 +120,8 @@ export const MetricGroupCard = ({ open={dataSourcesOpen} onClose={handleCloseDataSources} title={title} - metrics={uniqueMetrics} + rows={sourceRows} + buckets={buckets} initialFilters={initialFilters} /> )} diff --git a/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/__tests__/DataSourcesDialog.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/__tests__/DataSourcesDialog.test.tsx index 17e58b97224..fc8f4d03375 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/__tests__/DataSourcesDialog.test.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/__tests__/DataSourcesDialog.test.tsx @@ -16,9 +16,10 @@ import { render, screen, fireEvent } from '@testing-library/react'; import { ThemeProvider, createTheme } from '@mui/material/styles'; -import type { MetricResult } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; import { DataSourcesDialog } from '../DataSourcesDialog'; +import type { SourceRow } from '../DataSourcesDialogColumns'; +import type { ThresholdBucket } from '../types'; const mockTableProps = { 'aria-label': 'table', @@ -138,60 +139,56 @@ jest.mock('../ThresholdLegend', () => ({ ThresholdLegend: () =>
, })); +jest.mock('../../Common/CardLoading', () => ({ + CardLoading: ({ dataTestId }: { dataTestId?: string }) => ( +
+ ), +})); + +jest.mock('@backstage/core-components', () => ({ + ResponseErrorPanel: ({ error }: { error: Error }) => ( +
{error.message}
+ ), +})); + const TestWrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); -const mockMetrics: MetricResult[] = [ - { - id: 'sonarqube.reliabilityIssues', - status: 'success', - metadata: { - title: 'SonarQube Reliability Issues', - description: 'Count of open bugs in SonarQube.', - type: 'number', - history: true, - }, - result: { - value: 8, - timestamp: '2026-07-01T08:29:09.683Z', - thresholdResult: { - definition: { - rules: [ - { key: 'success', expression: '<1' }, - { key: 'warning', expression: '1-5' }, - { key: 'error', expression: '>5' }, - ], - }, - status: 'success', - evaluation: 'error', - }, - }, - }, +const createSourceRow = (overrides: Partial = {}): SourceRow => ({ + id: '0', + plugin: 'Sonarqube', + metricId: 'sonarqube.reliabilityIssues', + metricDescription: 'Count of open bugs in SonarQube.', + value: '8', + evaluationKey: 'error', + statusLabel: 'error', + statusIcon: 'scorecardSuccessStatusIcon', + statusColor: 'success.main', + lastSynced: '1 hour ago', + thresholdExpression: '>5', + ...overrides, +}); + +const mockRows: SourceRow[] = [ + createSourceRow(), + createSourceRow({ + id: '1', + metricId: 'sonarqube.codeCoverage', + metricDescription: 'Code coverage percentage.', + value: '72', + evaluationKey: 'warning', + statusLabel: 'warning', + thresholdExpression: '60-79', + }), +]; + +const mockBuckets: ThresholdBucket[] = [ { - id: 'sonarqube.codeCoverage', - status: 'success', - metadata: { - title: 'SonarQube Code Coverage', - description: 'Code coverage percentage.', - type: 'number', - history: true, - }, - result: { - value: 72, - timestamp: '2026-07-01T08:29:09.683Z', - thresholdResult: { - definition: { - rules: [ - { key: 'success', expression: '>=80' }, - { key: 'warning', expression: '60-79' }, - { key: 'error', expression: '<60' }, - ], - }, - status: 'success', - evaluation: 'warning', - }, - }, + key: 'error', + label: 'Error', + count: 1, + color: 'error.main', }, ]; @@ -199,7 +196,8 @@ const defaultProps = { open: true, onClose: jest.fn(), title: 'Code Quality', - metrics: mockMetrics, + rows: mockRows, + buckets: mockBuckets, }; describe('DataSourcesDialog', () => { @@ -237,7 +235,7 @@ describe('DataSourcesDialog', () => { ); }); - it('should render metric rows with plugin name, check title, value, status', () => { + it('should pass the provided rows through to the table', () => { const { useTable } = jest.requireMock('@backstage/ui'); let capturedData: any[] = []; useTable.mockImplementation(({ data }: any) => { @@ -266,29 +264,15 @@ describe('DataSourcesDialog', () => { expect(onClose).toHaveBeenCalledTimes(1); }); - it("should show '—' for null/undefined values", () => { - const metricsWithNull: MetricResult[] = [ - { - id: 'sonarqube.nullMetric', - status: 'error', - metadata: { - title: 'Null Metric', - description: 'A metric with no result value.', - type: 'number', - history: false, - }, - result: { - value: null as unknown as number, - timestamp: '2026-07-01T08:29:09.683Z', - thresholdResult: { - definition: { rules: [{ key: 'success', expression: '<1' }] }, - status: 'success', - evaluation: null as unknown as string, - }, - }, - }, - ]; + it('should hide the threshold legend when buckets are omitted', () => { + render(, { + wrapper: TestWrapper, + }); + expect(screen.queryByTestId('threshold-legend')).not.toBeInTheDocument(); + }); + + it('should pass collector-shaped rows through to the table', () => { const { useTable } = jest.requireMock('@backstage/ui'); let capturedData: any[] = []; useTable.mockImplementation(({ data }: any) => { @@ -296,12 +280,77 @@ describe('DataSourcesDialog', () => { return { tableProps: mockTableProps }; }); - render(, { - wrapper: TestWrapper, - }); + const collectorRows: SourceRow[] = [ + createSourceRow({ + plugin: 'GitHub', + metricId: 'dora.changeFailureRate', + metricDescription: 'Collects deployments from GitHub Actions.', + value: '--', + evaluationKey: 'noEvaluation', + statusLabel: '-- N/A', + statusIcon: '', + thresholdExpression: null, + }), + createSourceRow({ + id: '1', + plugin: 'Jira', + metricId: 'dora.changeFailureRate', + metricDescription: 'Collects Jira incidents.', + value: '--', + evaluationKey: 'noEvaluation', + statusLabel: '-- N/A', + statusIcon: '', + thresholdExpression: null, + }), + ]; + + render( + , + { wrapper: TestWrapper }, + ); + + expect(capturedData).toHaveLength(2); + expect(capturedData[0].plugin).toBe('GitHub'); + expect(capturedData[0].metricId).toBe('dora.changeFailureRate'); + expect(capturedData[0].metricDescription).toBe( + 'Collects deployments from GitHub Actions.', + ); + expect(capturedData[0].value).toBe('--'); + expect(capturedData[0].statusLabel).toBe('-- N/A'); + expect(capturedData[1].plugin).toBe('Jira'); + }); + + it('should show a loading state while rows are fetching', () => { + render( + , + { wrapper: TestWrapper }, + ); + + expect(screen.getByTestId('data-sources-loading')).toBeInTheDocument(); + expect(screen.queryByTestId('table')).not.toBeInTheDocument(); + }); + + it('should show an error panel when rows fail to load', () => { + render( + , + { wrapper: TestWrapper }, + ); - expect(capturedData[0].value).toBe('—'); - expect(capturedData[0].statusLabel).toBe('—'); - expect(capturedData[0].evaluationKey).toBe('noEvaluation'); + expect(screen.getByText('collectors unavailable')).toBeInTheDocument(); + expect(screen.queryByTestId('table')).not.toBeInTheDocument(); }); }); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/__tests__/DataSourcesDialogColumns.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/__tests__/DataSourcesDialogColumns.test.tsx index 25bd149f1b7..1dab69466f9 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/__tests__/DataSourcesDialogColumns.test.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/__tests__/DataSourcesDialogColumns.test.tsx @@ -318,4 +318,24 @@ describe('buildColumnConfig', () => { expect(screen.getByText('—')).toBeInTheDocument(); expect(screen.getByTestId('tooltip')).toHaveAttribute('data-title', ''); }); + + it('should show the collector explanation tooltip on N/A status', () => { + const columns = buildColumnConfig(mockT as any); + const statusCell = columns.find(c => c.id === 'status')?.cell; + const row = createRow({ + isCollector: true, + thresholdExpression: null, + evaluationKey: 'noEvaluation', + statusLabel: '-- N/A', + statusIcon: '', + value: '--', + }); + + render(<>{statusCell!(row)}, { wrapper: TestWrapper }); + + expect(screen.getByText('-- N/A')).toBeInTheDocument(); + expect(screen.getByTestId('tooltip').getAttribute('data-title')).toBe( + 'This collector provides input data only. The DORA metric value is calculated from collectors and shown on the scorecard card.', + ); + }); }); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/__tests__/MetricGroupCard.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/__tests__/MetricGroupCard.test.tsx index 7cbebe52b21..05218f38e25 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/__tests__/MetricGroupCard.test.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/__tests__/MetricGroupCard.test.tsx @@ -42,6 +42,14 @@ const mockBuckets: ThresholdBucket[] = [ }, ]; +jest.mock('../../../hooks/useLanguage', () => ({ + useLanguage: () => 'en', +})); + +jest.mock('../metricSourceRows', () => ({ + toMetricSourceRows: () => [], +})); + jest.mock('../thresholdBucketUtils', () => ({ buildThresholdBuckets: jest.fn(() => mockBuckets), dedupeMetricsById: (metrics: unknown[]) => metrics, diff --git a/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/__tests__/collectorSourceRows.test.ts b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/__tests__/collectorSourceRows.test.ts new file mode 100644 index 00000000000..5c4555a4c06 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/__tests__/collectorSourceRows.test.ts @@ -0,0 +1,93 @@ +/* + * 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 { toCollectorSourceRows } from '../collectorSourceRows'; + +describe('toCollectorSourceRows', () => { + const labels = { + metricId: 'dora.deploymentFrequency', + lastSynced: '1 hour ago', + unknownPlugin: 'Unknown', + emptyValue: '--', + unavailableStatus: 'N/A', + pluginLabels: { + github: 'GitHub', + jira: 'Jira', + }, + statusColor: '#ccc', + }; + + it('maps collector metadata into data-source rows', () => { + const rows = toCollectorSourceRows( + [ + { + id: 'github:deploymentWorkflowRuns', + description: 'Collects deployments from GitHub Actions.', + }, + { + id: 'jira:incidents', + description: 'Collects Jira incidents.', + }, + ], + labels, + ); + + expect(rows).toEqual([ + { + id: '0', + plugin: 'GitHub', + metricId: 'dora.deploymentFrequency', + metricDescription: 'Collects deployments from GitHub Actions.', + value: '--', + evaluationKey: 'noEvaluation', + statusLabel: 'N/A', + statusIcon: '', + statusColor: '#ccc', + lastSynced: '1 hour ago', + thresholdExpression: null, + isCollector: true, + }, + { + id: '1', + plugin: 'Jira', + metricId: 'dora.deploymentFrequency', + metricDescription: 'Collects Jira incidents.', + value: '--', + evaluationKey: 'noEvaluation', + statusLabel: 'N/A', + statusIcon: '', + statusColor: '#ccc', + lastSynced: '1 hour ago', + thresholdExpression: null, + isCollector: true, + }, + ]); + }); + + it('falls back to the collector id plugin name when no label is provided', () => { + const rows = toCollectorSourceRows( + [ + { + id: 'pagerduty:incidents', + description: 'Collects incidents from PagerDuty.', + }, + ], + labels, + ); + + expect(rows[0].plugin).toBe('Pagerduty'); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/__tests__/metricSourceRows.test.ts b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/__tests__/metricSourceRows.test.ts new file mode 100644 index 00000000000..c77523362c6 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/__tests__/metricSourceRows.test.ts @@ -0,0 +1,157 @@ +/* + * 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 type { MetricResult } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +import { mockT } from '../../../test-utils/mockTranslations'; +import { toMetricSourceRows } from '../metricSourceRows'; + +jest.mock('../../../utils', () => ({ + getStatusConfig: () => ({ + color: 'success.main', + icon: 'scorecardSuccessStatusIcon', + }), + getLastUpdatedLabel: () => '1 hour ago', + extractPluginName: () => 'Sonarqube', + resolveMetricTranslation: ( + _t: unknown, + _id: string, + _field: string, + fallback?: string, + ) => fallback ?? '', +})); + +jest.mock('../thresholdBucketUtils', () => ({ + MISSING_EVALUATION_BUCKET_KEY: 'noEvaluation', + MISSING_EVALUATION_LABEL: '—', + getMetricBucketKey: (metric: { + result?: { thresholdResult?: { evaluation?: string | null } }; + }) => metric.result?.thresholdResult?.evaluation ?? 'noEvaluation', + hasMetricEvaluation: (metric: { + result?: { thresholdResult?: { evaluation?: string | null } }; + }) => Boolean(metric.result?.thresholdResult?.evaluation), + getMetricBucketLabel: (bucketKey: string) => + bucketKey === 'noEvaluation' ? '—' : bucketKey, +})); + +const mockMetrics: MetricResult[] = [ + { + id: 'sonarqube.reliabilityIssues', + status: 'success', + metadata: { + title: 'SonarQube Reliability Issues', + description: 'Count of open bugs in SonarQube.', + type: 'number', + history: true, + }, + result: { + value: 8, + timestamp: '2026-07-01T08:29:09.683Z', + thresholdResult: { + definition: { + rules: [ + { key: 'success', expression: '<1' }, + { key: 'warning', expression: '1-5' }, + { key: 'error', expression: '>5' }, + ], + }, + status: 'success', + evaluation: 'error', + }, + }, + }, + { + id: 'sonarqube.codeCoverage', + status: 'success', + metadata: { + title: 'SonarQube Code Coverage', + description: 'Code coverage percentage.', + type: 'number', + history: true, + }, + result: { + value: 72, + timestamp: '2026-07-01T08:29:09.683Z', + thresholdResult: { + definition: { + rules: [ + { key: 'success', expression: '>=80' }, + { key: 'warning', expression: '60-79' }, + { key: 'error', expression: '<60' }, + ], + }, + status: 'success', + evaluation: 'warning', + }, + }, + }, +]; + +describe('toMetricSourceRows', () => { + it('maps metric results into data-source rows', () => { + const rows = toMetricSourceRows(mockMetrics, { + t: mockT as any, + locale: 'en', + }); + + expect(rows).toHaveLength(2); + expect(rows[0].plugin).toBe('Sonarqube'); + expect(rows[0].metricId).toBe('sonarqube.reliabilityIssues'); + expect(rows[0].metricDescription).toBe('Count of open bugs in SonarQube.'); + expect(rows[0].value).toBe('8'); + expect(rows[0].statusLabel).toBe('error'); + expect(rows[0].evaluationKey).toBe('error'); + expect(rows[0].thresholdExpression).toBe('>5'); + expect(rows[0].lastSynced).toBe('1 hour ago'); + expect(rows[1].value).toBe('72'); + expect(rows[1].statusLabel).toBe('warning'); + }); + + it('uses placeholders when a metric has no value or evaluation', () => { + const metricsWithNull: MetricResult[] = [ + { + id: 'sonarqube.nullMetric', + status: 'error', + metadata: { + title: 'Null Metric', + description: 'A metric with no result value.', + type: 'number', + history: false, + }, + result: { + value: null as unknown as number, + timestamp: '2026-07-01T08:29:09.683Z', + thresholdResult: { + definition: { rules: [{ key: 'success', expression: '<1' }] }, + status: 'success', + evaluation: null as unknown as string, + }, + }, + }, + ]; + + const rows = toMetricSourceRows(metricsWithNull, { + t: mockT as any, + locale: 'en', + }); + + expect(rows[0].value).toBe('—'); + expect(rows[0].statusLabel).toBe('—'); + expect(rows[0].evaluationKey).toBe('noEvaluation'); + expect(rows[0].statusIcon).toBe(''); + expect(rows[0].thresholdExpression).toBeNull(); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/collectorSourceRows.ts b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/collectorSourceRows.ts new file mode 100644 index 00000000000..94924f5381e --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/collectorSourceRows.ts @@ -0,0 +1,64 @@ +/* + * 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 type { CollectorMetadata } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +import { extractPluginName } from '../../utils'; +import { MISSING_EVALUATION_BUCKET_KEY } from './thresholdBucketUtils'; +import type { SourceRow } from './DataSourcesDialogColumns'; + +const pluginLabelFromCollectorId = ( + collectorId: string, + unknownPlugin: string, + pluginLabels: Record, +): string => { + const prefix = collectorId.split(/[.:]/)[0]?.toLowerCase(); + if (prefix && pluginLabels[prefix]) { + return pluginLabels[prefix]; + } + return extractPluginName(collectorId, unknownPlugin); +}; + +export const toCollectorSourceRows = ( + collectors: CollectorMetadata[], + options: { + metricId: string; + lastSynced: string; + unknownPlugin: string; + emptyValue: string; + unavailableStatus: string; + pluginLabels: Record; + statusColor: string; + }, +): SourceRow[] => + collectors.map((collector, index) => ({ + id: String(index), + plugin: pluginLabelFromCollectorId( + collector.id, + options.unknownPlugin, + options.pluginLabels, + ), + metricId: options.metricId, + metricDescription: collector.description, + value: options.emptyValue, + evaluationKey: MISSING_EVALUATION_BUCKET_KEY, + statusLabel: options.unavailableStatus, + statusIcon: '', + statusColor: options.statusColor, + lastSynced: options.lastSynced, + thresholdExpression: null, + isCollector: true, + })); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/metricSourceRows.ts b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/metricSourceRows.ts new file mode 100644 index 00000000000..a8c76b07dc0 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/metricSourceRows.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 type { MetricResult } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import type { TranslationFunction } from '@backstage/core-plugin-api/alpha'; + +import { scorecardTranslationRef } from '../../translations'; +import { + extractPluginName, + getLastUpdatedLabel, + getStatusConfig, + resolveMetricTranslation, +} from '../../utils'; +import { formatMetricValue } from './DataSourcesDialogColumns'; +import type { SourceRow } from './DataSourcesDialogColumns'; +import { + getMetricBucketKey, + getMetricBucketLabel, + hasMetricEvaluation, + MISSING_EVALUATION_LABEL, +} from './thresholdBucketUtils'; + +type ScorecardTranslate = TranslationFunction; + +export const toMetricSourceRows = ( + metrics: MetricResult[], + options: { + t: ScorecardTranslate; + locale: string; + }, +): SourceRow[] => + metrics.map((metric, index) => { + const evaluationKey = getMetricBucketKey(metric); + const evaluated = hasMetricEvaluation(metric); + const thresholdRules = + metric.result?.thresholdResult?.definition?.rules ?? []; + + const statusConfig = getStatusConfig({ + evaluation: evaluated ? evaluationKey : null, + thresholdStatus: metric.result?.thresholdResult?.status, + metricStatus: metric.status, + thresholdRules, + }); + + const matchedRule = evaluated + ? thresholdRules.find(r => r.key === evaluationKey) + : undefined; + + return { + id: String(index), + plugin: extractPluginName( + metric.id, + options.t('dataSourcesDialog.unknownPlugin'), + ), + metricId: metric.id, + metricDescription: resolveMetricTranslation( + options.t, + metric.id, + 'description', + metric.metadata.description, + ), + value: formatMetricValue(metric.result), + evaluationKey, + statusLabel: getMetricBucketLabel(evaluationKey, options.t), + statusIcon: evaluated ? statusConfig.icon ?? '' : '', + statusColor: statusConfig.color, + lastSynced: metric.result?.timestamp + ? getLastUpdatedLabel(metric.result.timestamp, options.locale) + : MISSING_EVALUATION_LABEL, + thresholdExpression: matchedRule?.expression ?? null, + unit: metric.metadata.unit, + }; + }); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/EntityMetricCard.tsx b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/EntityMetricCard.tsx new file mode 100644 index 00000000000..2e0fdd2467a --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/EntityMetricCard.tsx @@ -0,0 +1,82 @@ +/* + * 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 type { MetricResult } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +import Box from '@mui/material/Box'; + +import { useTranslation } from '../../hooks/useTranslation'; +import { getStatusConfig, resolveMetricTranslation } from '../../utils'; +import { isSparklineVisualization } from '../../utils/metricVisualization'; +import { hasMetricDataError, hasThresholdError } from '../../utils/statusUtils'; +import { EntitySparklineCard } from './EntitySparklineCard'; +import Scorecard from './Scorecard'; + +export const EntityMetricCard = ({ metric }: { metric: MetricResult }) => { + const { t } = useTranslation(); + const title = resolveMetricTranslation( + t, + metric.id, + 'title', + metric.metadata.title, + ); + const description = resolveMetricTranslation( + t, + metric.id, + 'description', + metric.metadata.description, + ); + + if (isSparklineVisualization(metric.metadata.defaultVisualization)) { + return ( + + + + ); + } + + const isMetricDataError = hasMetricDataError(metric); + const isThresholdError = hasThresholdError(metric); + const statusConfig = getStatusConfig({ + evaluation: metric.result?.thresholdResult?.evaluation, + thresholdStatus: metric.result?.thresholdResult?.status, + metricStatus: metric.status, + thresholdRules: metric.result?.thresholdResult?.definition?.rules, + }); + + return ( + + + + ); +}; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/EntityScorecardContent.tsx b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/EntityScorecardContent.tsx index b9ac25ff305..4d4de3f861f 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/EntityScorecardContent.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/EntityScorecardContent.tsx @@ -20,17 +20,13 @@ import { ResponseErrorPanel } from '@backstage/core-components'; import Box from '@mui/material/Box'; import NoScorecardsState from '../Common/NoScorecardsState'; -import Scorecard from './Scorecard'; import { useScorecards } from '../../hooks/useScorecards'; -import { getStatusConfig, resolveMetricTranslation } from '../../utils'; import PermissionRequiredState from '../Common/PermissionRequiredState'; -import { useTranslation } from '../../hooks/useTranslation'; import { CardLoading } from '../Common/CardLoading'; -import { hasMetricDataError, hasThresholdError } from '../../utils/statusUtils'; +import { EntityMetricCard } from './EntityMetricCard'; const EntityScorecardContentInner = () => { const { data: scorecards, isLoading, error } = useScorecards(); - const { t } = useTranslation(); if (isLoading) { return ; @@ -58,51 +54,9 @@ const EntityScorecardContentInner = () => { gap={2} sx={{ alignItems: 'start' }} > - {scorecards?.map((metric: MetricResult) => { - // Check if metric data unavailable - const isMetricDataError = hasMetricDataError(metric); - - // Check if threshold has an error - const isThresholdError = hasThresholdError(metric); - - const statusConfig = getStatusConfig({ - evaluation: metric.result?.thresholdResult?.evaluation, - thresholdStatus: metric.result?.thresholdResult?.status, - metricStatus: metric.status, - thresholdRules: metric.result?.thresholdResult?.definition?.rules, - }); - - const title = resolveMetricTranslation( - t, - metric.id, - 'title', - metric.metadata.title, - ); - const description = resolveMetricTranslation( - t, - metric.id, - 'description', - metric.metadata.description, - ); - - return ( - - ); - })} + {scorecards?.map((metric: MetricResult) => ( + + ))} ); }; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/EntitySparklineCard.tsx b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/EntitySparklineCard.tsx new file mode 100644 index 00000000000..f7ceb01271d --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/EntitySparklineCard.tsx @@ -0,0 +1,237 @@ +/* + * 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 { useCallback, useMemo, useState } from 'react'; + +import type { MetricResult } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import { ResponseErrorPanel } from '@backstage/core-components'; + +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import { useTheme } from '@mui/material/styles'; +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; + +import { ScorecardQueryProvider } from '../../api'; +import { CardWrapper } from '../Common/CardWrapper'; +import { CardLoading } from '../Common/CardLoading'; +import { DataSourcesDialog } from '../MetricGroupCard/DataSourcesDialog'; +import { MetricGroupCardMenu } from '../MetricGroupCard/MetricGroupCardMenu'; +import { SparklineChart } from '../SparklineChart'; +import { useLanguage } from '../../hooks/useLanguage'; +import { useMetricCollectors } from '../../hooks/useMetricCollectors'; +import { useMetricTimeSeries } from '../../hooks/useMetricTimeSeries'; +import { useTranslation } from '../../hooks/useTranslation'; +import { formatDate } from '../../utils/entityTableUtils'; +import { + getLastUpdatedLabel, + getStatusConfig, + resolveStatusColor, +} from '../../utils'; +import { toSparklineChartModel } from '../../utils/sparklineChartModel'; +import { toMetricSparklinePoints } from '../../utils/timeSeriesChartData'; +import { toCollectorSourceRows } from '../MetricGroupCard/collectorSourceRows'; +import { MISSING_EVALUATION_LABEL } from '../MetricGroupCard/thresholdBucketUtils'; + +export type EntitySparklineCardProps = { + metric: MetricResult; + title: string; + description: string; +}; + +const EntitySparklineCardContent = ({ + metric, + title, + description, +}: EntitySparklineCardProps) => { + const theme = useTheme(); + const locale = useLanguage(); + const { t } = useTranslation(); + const [dataSourcesOpen, setDataSourcesOpen] = useState(false); + const handleOpenDataSources = useCallback(() => setDataSourcesOpen(true), []); + const handleCloseDataSources = useCallback( + () => setDataSourcesOpen(false), + [], + ); + const menuActions = useMemo( + () => [ + { + id: 'view-data-sources', + label: t('metricGroupCard.viewDataSources'), + icon: , + onClick: handleOpenDataSources, + }, + ], + [t, handleOpenDataSources], + ); + const { + data: series, + isLoading, + error: seriesError, + } = useMetricTimeSeries(metric.id); + const collectorIds = metric.metadata.collectorIds ?? []; + const shouldFetchCollectors = dataSourcesOpen && collectorIds.length > 0; + const { + data: collectors, + isLoading: collectorsLoading, + error: collectorsError, + } = useMetricCollectors(metric.id, shouldFetchCollectors); + + const unit = series?.metadata.unit ?? metric.metadata.unit; + const thresholds = metric.result?.thresholdResult; + const matchedRule = thresholds?.definition?.rules?.find( + rule => rule.key === thresholds.evaluation, + ); + const fallbackErrorLabel = t('errors.metricDataUnavailable'); + const { chartData, chartColor, strokeDasharray, legendItems } = useMemo( + () => + toSparklineChartModel({ + inputPoints: toMetricSparklinePoints( + series?.points ?? [], + fallbackErrorLabel, + ), + formatDateLabel: timestamp => + formatDate( + new Date(timestamp), + { month: 'short', day: 'numeric' }, + locale, + ), + matchingThresholdKey: matchedRule?.key, + chartColor: resolveStatusColor( + theme, + getStatusConfig({ + evaluation: thresholds?.evaluation ?? null, + thresholdStatus: thresholds?.status, + metricStatus: metric.status, + thresholdRules: thresholds?.definition?.rules, + }).color, + ), + unit, + theme, + t, + legendRules: matchedRule ? [matchedRule] : undefined, + }), + [ + series?.points, + fallbackErrorLabel, + locale, + matchedRule, + theme, + thresholds?.evaluation, + thresholds?.status, + thresholds?.definition?.rules, + metric.status, + unit, + t, + ], + ); + + const sourceRows = useMemo(() => { + const unevaluatedStatus = getStatusConfig({ + evaluation: null, + thresholdStatus: undefined, + metricStatus: undefined, + thresholdRules: [], + }); + + return toCollectorSourceRows(collectors ?? [], { + metricId: metric.id, + lastSynced: metric.result?.timestamp + ? getLastUpdatedLabel(metric.result.timestamp, locale) + : MISSING_EVALUATION_LABEL, + unknownPlugin: t('dataSourcesDialog.unknownPlugin'), + emptyValue: t('dataSourcesDialog.collectorEmptyValue'), + unavailableStatus: t('dataSourcesDialog.collectorUnavailableStatus'), + pluginLabels: { + github: t('dataSourcesDialog.pluginGithub'), + jira: t('dataSourcesDialog.pluginJira'), + }, + statusColor: unevaluatedStatus.color, + }); + }, [collectors, metric.id, metric.result?.timestamp, locale, t]); + + const renderContent = () => { + if (isLoading) { + return ; + } + + if (seriesError) { + return ; + } + + if (chartData.length === 0) { + return ( + + + {t('errors.noDataFound')} + + + ); + } + + return ( + + ); + }; + + return ( + <> + + } + > + {renderContent()} + + {dataSourcesOpen && ( + + )} + + ); +}; + +export const EntitySparklineCard = (props: EntitySparklineCardProps) => ( + + + +); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/ScorecardEntityContentGridView.tsx b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/ScorecardEntityContentGridView.tsx index 69265c25c9f..358af126dbe 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/ScorecardEntityContentGridView.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/ScorecardEntityContentGridView.tsx @@ -16,27 +16,22 @@ import type { MetricResult } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; import { ResponseErrorPanel } from '@backstage/core-components'; -import Box from '@mui/material/Box'; import Masonry from '@mui/lab/Masonry'; import { ScorecardLayoutProps } from '../../blueprints/ScorecardLayoutBlueprint'; import { useScorecards } from '../../hooks/useScorecards'; -import { useTranslation } from '../../hooks/useTranslation'; import NoScorecardsState from '../Common/NoScorecardsState'; import PermissionRequiredState from '../Common/PermissionRequiredState'; import { CardLoading } from '../Common/CardLoading'; import { MetricGroupCard } from '../MetricGroupCard'; import { dedupeMetricsById } from '../MetricGroupCard/thresholdBucketUtils'; import { EntityScorecardContent } from './EntityScorecardContent'; -import Scorecard from './Scorecard'; -import { getStatusConfig, resolveMetricTranslation } from '../../utils'; -import { hasMetricDataError, hasThresholdError } from '../../utils/statusUtils'; +import { EntityMetricCard } from './EntityMetricCard'; export const ScorecardEntityContentGridView = ({ groups, }: ScorecardLayoutProps) => { const { data: scorecards, isLoading, error } = useScorecards(); - const { t } = useTranslation(); if (isLoading) return ; @@ -63,10 +58,8 @@ export const ScorecardEntityContentGridView = ({ .filter((m): m is MetricResult => m !== undefined), ); - if (metricsInOrder.length > 0) { - groupedMetrics.set(groupKey, metricsInOrder); - metricsInOrder.forEach(m => groupedMetricIds.add(m.id)); - } + metricsInOrder.forEach(metric => groupedMetricIds.add(metric.id)); + groupedMetrics.set(groupKey, metricsInOrder); }); const ungroupedMetrics = scorecards.filter(m => !groupedMetricIds.has(m.id)); @@ -87,45 +80,9 @@ export const ScorecardEntityContentGridView = ({ }) .filter(Boolean); - const ungroupedCards = ungroupedMetrics.map((metric: MetricResult) => { - const metricDataError = hasMetricDataError(metric); - const thresholdErrorState = hasThresholdError(metric); - const statusConfig = getStatusConfig({ - evaluation: metric.result?.thresholdResult?.evaluation, - thresholdStatus: metric.result?.thresholdResult?.status, - metricStatus: metric.status, - thresholdRules: metric.result?.thresholdResult?.definition?.rules, - }); - - return ( - - - - ); - }); + const ungroupedCards = ungroupedMetrics.map((metric: MetricResult) => ( + + )); return ( diff --git a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/__tests__/EntityScorecardContent.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/__tests__/EntityScorecardContent.test.tsx index 8b84b056f1c..c68e65228dc 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/__tests__/EntityScorecardContent.test.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/__tests__/EntityScorecardContent.test.tsx @@ -82,10 +82,31 @@ jest.mock('../Scorecard', () => { }; }); +jest.mock('../EntitySparklineCard', () => ({ + EntitySparklineCard: function MockEntitySparklineCard({ + title, + description, + }: { + title: string; + description: string; + }) { + return ( +
+

{title}

+

{description}

+
+ ); + }, +})); + jest.mock('../../../hooks/useScorecards', () => ({ useScorecards: jest.fn(), })); +jest.mock('../../../hooks/useMetricTimeSeries', () => ({ + useMetricTimeSeries: jest.fn(), +})); + jest.mock('../../../utils', () => ({ getStatusConfig: jest.fn(), resolveMetricTranslation: jest.fn( @@ -347,4 +368,61 @@ describe('EntityScorecardContent Component', () => { screen.getByText('Metric Error: Failed to fetch metric data'), ).toBeInTheDocument(); }); + + it('should render a sparkline beside score donuts when visualization is sparkline', () => { + const doraMetric = { + ...mockScorecardSuccessData[0], + id: 'dora.changeFailureRate', + metadata: { + ...mockScorecardSuccessData[0].metadata, + title: 'DORA - Change Failure Rate', + description: 'Percentage of changes that fail in production.', + defaultVisualization: 'sparkline' as const, + }, + }; + + useScorecardsMock.mockReturnValue({ + data: [doraMetric, mockScorecardSuccessData[0]], + isLoading: false, + error: undefined, + }); + + render(); + + expect(screen.getByTestId('area-chart-card')).toHaveAttribute( + 'data-title', + 'DORA - Change Failure Rate', + ); + expect(screen.getByTestId('scorecard-card')).toHaveAttribute( + 'data-title', + 'GitHub open PRs', + ); + expect(getStatusConfigMock).toHaveBeenCalledTimes(1); + }); + + it('should render a sparkline chart when defaultVisualization is sparkline', () => { + const sparklineMetric = { + ...mockScorecardSuccessData[0], + id: 'custom.trend', + metadata: { + ...mockScorecardSuccessData[0].metadata, + title: 'Custom Trend', + defaultVisualization: 'sparkline' as const, + }, + }; + + useScorecardsMock.mockReturnValue({ + data: [sparklineMetric], + isLoading: false, + error: undefined, + }); + + render(); + + expect(screen.getByTestId('area-chart-card')).toHaveAttribute( + 'data-title', + 'Custom Trend', + ); + expect(screen.queryByTestId('scorecard-card')).not.toBeInTheDocument(); + }); }); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/__tests__/EntitySparklineCard.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/__tests__/EntitySparklineCard.test.tsx new file mode 100644 index 00000000000..d0b92d0827b --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/__tests__/EntitySparklineCard.test.tsx @@ -0,0 +1,442 @@ +/* + * 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 { fireEvent, render, screen } from '@testing-library/react'; +import { + ScorecardThresholdRuleColors, + type MetricResult, +} from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +import { EntitySparklineCard } from '../EntitySparklineCard'; +import { useMetricCollectors } from '../../../hooks/useMetricCollectors'; +import { useMetricTimeSeries } from '../../../hooks/useMetricTimeSeries'; + +jest.mock('../../../hooks/useMetricTimeSeries', () => ({ + useMetricTimeSeries: jest.fn(), +})); + +jest.mock('../../../hooks/useMetricCollectors', () => ({ + useMetricCollectors: jest.fn(), +})); + +jest.mock('../../../hooks/useLanguage', () => ({ + useLanguage: () => 'en', +})); + +jest.mock('../../MetricGroupCard/MetricGroupCardMenu', () => ({ + MetricGroupCardMenu: ({ + actions, + }: { + actions: Array<{ id: string; label: string; onClick: () => void }>; + }) => ( +
+ {actions.map(action => ( + + ))} +
+ ), +})); + +jest.mock('../../MetricGroupCard/DataSourcesDialog', () => ({ + DataSourcesDialog: ({ + title, + rows, + isLoading, + error, + buckets, + }: { + title: string; + rows: Array<{ plugin: string; metricId: string }>; + isLoading?: boolean; + error?: Error; + buckets?: unknown[]; + }) => ( +
+ {title} + {rows[0]?.metricId ?? ''} + + {rows.map(row => row.plugin).join(',')} + + {String(Boolean(isLoading))} + {error?.message ?? ''} + {String(Boolean(buckets))} +
+ ), +})); + +jest.mock('@backstage/core-components', () => ({ + ResponseErrorPanel: ({ error }: { error: Error }) => ( +
{error.message}
+ ), +})); + +jest.mock('recharts', () => { + const actual = jest.requireActual('recharts'); + return { + ...actual, + ResponsiveContainer: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + }; +}); + +const useMetricTimeSeriesMock = useMetricTimeSeries as jest.Mock; +const useMetricCollectorsMock = useMetricCollectors as jest.Mock; + +const metric: MetricResult = { + id: 'dora.changeFailureRate', + status: 'success', + metadata: { + title: 'DORA - Change Failure Rate', + description: 'Change failure rate', + type: 'number', + unit: '%', + history: true, + defaultVisualization: 'sparkline', + collectorIds: ['github:deploymentWorkflowRuns', 'jira:incidents'], + }, + result: { + value: 4.2, + timestamp: '2026-04-30T10:00:00.000Z', + thresholdResult: { + status: 'success', + definition: { rules: [] }, + evaluation: 'success', + }, + }, +}; + +const lowChangeFailureRateMetric: MetricResult = { + ...metric, + result: { + ...metric.result, + value: 22, + thresholdResult: { + status: 'success', + evaluation: 'low', + definition: { + rules: [ + { + key: 'elite', + expression: '<5', + color: ScorecardThresholdRuleColors.SUCCESS, + }, + { + key: 'medium', + expression: '5-15', + color: ScorecardThresholdRuleColors.WARNING, + }, + { + key: 'low', + expression: '>15', + color: ScorecardThresholdRuleColors.ERROR, + }, + ], + }, + }, + }, +}; + +const mockTimeSeries = (metricId: string) => ({ + data: { + metricId, + entityRef: 'component:default/svc', + points: [ + { value: 18, timestamp: '2026-04-27T12:00:00.000Z' }, + { value: 22, timestamp: '2026-04-30T12:00:00.000Z' }, + ], + metadata: metric.metadata, + }, + isLoading: false, + error: undefined, +}); + +describe('EntitySparklineCard', () => { + beforeEach(() => { + jest.clearAllMocks(); + useMetricCollectorsMock.mockReturnValue({ + data: undefined, + isLoading: false, + error: undefined, + }); + }); + + it('should fetch time series for the given metric', () => { + useMetricTimeSeriesMock.mockReturnValue({ + data: undefined, + isLoading: true, + error: undefined, + }); + + render( + , + ); + + expect(useMetricTimeSeriesMock).toHaveBeenCalledWith(metric.id); + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('should render a fetch error inside the card', () => { + useMetricTimeSeriesMock.mockReturnValue({ + data: undefined, + isLoading: false, + error: new Error('GitHub API 500'), + }); + + render( + , + ); + + expect(screen.getByTestId('error-panel')).toHaveTextContent( + 'GitHub API 500', + ); + }); + + it('should render an empty state when the series has no points', () => { + useMetricTimeSeriesMock.mockReturnValue({ + data: { + metricId: 'dora.changeFailureRate', + entityRef: 'component:default/svc', + points: [], + metadata: metric.metadata, + }, + isLoading: false, + error: undefined, + }); + + render( + , + ); + + expect(screen.getByText('No data found')).toBeInTheDocument(); + }); + + it('should still render the chart when a point is a calculation failure', () => { + useMetricTimeSeriesMock.mockReturnValue({ + data: { + metricId: 'dora.changeFailureRate', + entityRef: 'component:default/svc', + points: [ + { value: 4.2, timestamp: '2026-04-27T12:00:00.000Z' }, + { + value: null, + timestamp: '2026-04-28T12:00:00.000Z', + error: 'GitHub API 500', + }, + { value: 3.8, timestamp: '2026-04-30T12:00:00.000Z' }, + ], + metadata: metric.metadata, + }, + isLoading: false, + error: undefined, + }); + + render( + , + ); + + expect( + screen.getByTestId('sparkline-chart-dora.changeFailureRate'), + ).toBeInTheDocument(); + expect(screen.queryByTestId('error-panel')).not.toBeInTheDocument(); + expect(screen.queryByText('No data found')).not.toBeInTheDocument(); + }); + + it('should render the chart when time series points are available', () => { + useMetricTimeSeriesMock.mockReturnValue({ + data: { + metricId: 'dora.changeFailureRate', + entityRef: 'component:default/svc', + points: [ + { value: 4.2, timestamp: '2026-04-27T12:00:00.000Z' }, + { value: 3.8, timestamp: '2026-04-30T12:00:00.000Z' }, + ], + metadata: metric.metadata, + }, + isLoading: false, + error: undefined, + }); + + render( + , + ); + + expect( + screen.getByTestId('sparkline-chart-dora.changeFailureRate'), + ).toBeInTheDocument(); + expect(screen.getByTestId('responsive-container')).toBeInTheDocument(); + expect( + screen.queryByTestId('sparkline-threshold-legend-dora.changeFailureRate'), + ).not.toBeInTheDocument(); + }); + + it('should color the sparkline from the matched metric threshold and show a legend', () => { + useMetricTimeSeriesMock.mockReturnValue( + mockTimeSeries('dora.changeFailureRate'), + ); + + render( + , + ); + + expect(screen.getByText('Low (>15%)')).toBeInTheDocument(); + expect(screen.queryByText('Elite (<5%)')).not.toBeInTheDocument(); + expect(screen.queryByText('Medium (5-15%)')).not.toBeInTheDocument(); + expect( + screen.getByTestId('sparkline-threshold-legend-dora.changeFailureRate'), + ).toBeInTheDocument(); + expect(screen.getByTestId('sparkline-threshold-color')).toHaveAttribute( + 'stroke', + '#d32f2f', + ); + }); + + it('should update the legend label when the metric evaluates to a different threshold', () => { + useMetricTimeSeriesMock.mockReturnValue( + mockTimeSeries('dora.changeFailureRate'), + ); + + const eliteMetric: MetricResult = { + ...lowChangeFailureRateMetric, + result: { + ...lowChangeFailureRateMetric.result, + value: 2, + thresholdResult: { + ...lowChangeFailureRateMetric.result.thresholdResult, + evaluation: 'elite', + }, + }, + }; + + render( + , + ); + + expect(screen.getByText('Elite (<5%)')).toBeInTheDocument(); + expect(screen.queryByText('Low (>15%)')).not.toBeInTheDocument(); + expect(screen.getByTestId('sparkline-threshold-color')).toHaveAttribute( + 'stroke', + '#2e7d32', + ); + }); + + it('should open the data sources dialog with collectors after the menu click', () => { + useMetricTimeSeriesMock.mockReturnValue( + mockTimeSeries('dora.changeFailureRate'), + ); + useMetricCollectorsMock.mockReturnValue({ + data: [ + { + id: 'github:deploymentWorkflowRuns', + description: 'Collects deployments from GitHub Actions.', + }, + { + id: 'jira:incidents', + description: 'Collects Jira incidents.', + }, + ], + isLoading: false, + error: undefined, + }); + + render( + , + ); + + expect(screen.queryByTestId('data-sources-dialog')).not.toBeInTheDocument(); + expect(useMetricCollectorsMock).toHaveBeenCalledWith(metric.id, false); + + fireEvent.click(screen.getByTestId('menu-action-view-data-sources')); + + expect(screen.getByTestId('data-sources-dialog')).toBeInTheDocument(); + expect(screen.getByTestId('dialog-title')).toHaveTextContent( + 'DORA - Change Failure Rate', + ); + expect(screen.getByTestId('dialog-metric-id')).toHaveTextContent( + 'dora.changeFailureRate', + ); + expect(screen.getByTestId('dialog-collectors')).toHaveTextContent( + 'GitHub,Jira', + ); + expect(screen.getByTestId('dialog-legend')).toHaveTextContent('false'); + expect(useMetricCollectorsMock).toHaveBeenCalledWith(metric.id, true); + }); + + it('should not fetch collectors when the metric has no collector ids', () => { + useMetricTimeSeriesMock.mockReturnValue( + mockTimeSeries('dora.changeFailureRate'), + ); + + const metricWithoutCollectors: MetricResult = { + ...metric, + metadata: { + ...metric.metadata, + collectorIds: [], + }, + }; + + render( + , + ); + + fireEvent.click(screen.getByTestId('menu-action-view-data-sources')); + + expect(useMetricCollectorsMock).toHaveBeenCalledWith(metric.id, false); + expect(screen.getByTestId('dialog-collectors')).toHaveTextContent(''); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/__tests__/ScorecardEntityContentGridView.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/__tests__/ScorecardEntityContentGridView.test.tsx index 630e753279a..b4b4e8d61ec 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/__tests__/ScorecardEntityContentGridView.test.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/__tests__/ScorecardEntityContentGridView.test.tsx @@ -40,6 +40,17 @@ jest.mock('../../Common/PermissionRequiredState', () => { }; }); +jest.mock('../EntitySparklineCard', () => ({ + EntitySparklineCard: function MockEntitySparklineCard({ + title, + }: { + title: string; + description: string; + }) { + return
; + }, +})); + jest.mock('../Scorecard', () => { return function MockScorecard({ cardTitle, @@ -118,6 +129,10 @@ jest.mock('../../../hooks/useScorecards', () => ({ useScorecards: jest.fn(), })); +jest.mock('../../../hooks/useMetricTimeSeries', () => ({ + useMetricTimeSeries: jest.fn(), +})); + jest.mock('../../../utils', () => ({ getStatusConfig: jest.fn(), resolveMetricTranslation: jest.fn( @@ -462,4 +477,78 @@ describe('ScorecardEntityContentGridView', () => { expect(screen.getByTestId('metric-group-card')).toBeInTheDocument(); expect(screen.getAllByTestId('scorecard-card')).toHaveLength(1); }); + + it('should render ungrouped sparkline metrics as individual cards', () => { + const doraMetric = { + ...mockScorecardSuccessData[1], + id: 'dora.deploymentFrequency', + metadata: { + ...mockScorecardSuccessData[1].metadata, + title: 'DORA - Deployment Frequency', + defaultVisualization: 'sparkline' as const, + }, + }; + + useScorecardsMock.mockReturnValue({ + data: [mockScorecardSuccessData[0], doraMetric], + isLoading: false, + error: undefined, + }); + + const groups = { + codeQuality: { + title: 'Code Quality', + metrics: ['github.openPRs'], + }, + }; + + render(); + + expect(screen.getByTestId('metric-group-card')).toBeInTheDocument(); + expect(screen.getByTestId('area-chart-card')).toHaveAttribute( + 'data-title', + 'DORA - Deployment Frequency', + ); + expect(screen.queryByTestId('scorecard-card')).not.toBeInTheDocument(); + }); + + it('should keep sparkline metrics inside their group tile', () => { + const doraMetric = { + ...mockScorecardSuccessData[0], + id: 'dora.deploymentFrequency', + metadata: { + ...mockScorecardSuccessData[0].metadata, + title: 'DORA - Deployment Frequency', + defaultVisualization: 'sparkline' as const, + }, + }; + + useScorecardsMock.mockReturnValue({ + data: [doraMetric, mockScorecardSuccessData[1]], + isLoading: false, + error: undefined, + }); + + const groups = { + delivery: { + title: 'Delivery', + metrics: ['dora.deploymentFrequency', 'jira.openIssues'], + }, + }; + + render(); + + expect(screen.getByTestId('metric-group-card')).toHaveAttribute( + 'data-title', + 'Delivery', + ); + expect(screen.getByTestId('group-metric-count')).toHaveTextContent('2'); + expect( + screen.getByTestId('group-metric-dora.deploymentFrequency'), + ).toBeInTheDocument(); + expect( + screen.getByTestId('group-metric-jira.openIssues'), + ).toBeInTheDocument(); + expect(screen.queryByTestId('area-chart-card')).not.toBeInTheDocument(); + }); }); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/SparklineChart/SparklineChart.tsx b/workspaces/scorecard/plugins/scorecard/src/components/SparklineChart/SparklineChart.tsx new file mode 100644 index 00000000000..d3642b529c3 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/SparklineChart/SparklineChart.tsx @@ -0,0 +1,271 @@ +/* + * 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 { useId, useState } from 'react'; + +import { + Area, + AreaChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +import Box from '@mui/material/Box'; +import { useTheme } from '@mui/material/styles'; + +import { + getSparklineYDomain, + type SparklineChartPoint, +} from '../../utils/timeSeriesChartData'; +import type { SparklineLegendItem } from '../../utils/sparklineLegend'; +import { SparklineLegend } from './SparklineLegend'; +import { SparklineTooltip } from './SparklineTooltip'; + +const PLOT_HEIGHT = 90; +const X_AXIS_HEIGHT = 30; + +const TOOLTIP_WRAPPER_STYLE = { + outline: 'none', + pointerEvents: 'none', + position: 'relative', + transform: 'none', + width: '100%', + left: 0, + top: 0, +} as const; + +export type SparklineChartProps = { + data: SparklineChartPoint[]; + color: string; + unit?: string; + testId?: string; + strokeDasharray?: string; + legendItems?: SparklineLegendItem[]; + legendTestId?: string; +}; + +export const SparklineChart = ({ + data, + color, + unit, + testId, + strokeDasharray, + legendItems, + legendTestId, +}: SparklineChartProps) => { + const gradientId = `sparklineGradient${useId().replace(/:/g, '')}`; + const [tooltipPortal, setTooltipPortal] = useState( + null, + ); + const theme = useTheme(); + const errorColor = theme.palette.error.main; + const axisTickColor = theme.palette.text.secondary; + const markerStroke = theme.palette.background.paper; + const firstDate = data[0]?.date; + const lastDate = data[data.length - 1]?.date; + const xTicks: string[] = []; + if (firstDate) { + xTicks.push(firstDate); + } + if (lastDate && lastDate !== firstDate) { + xTicks.push(lastDate); + } + const yDomain = getSparklineYDomain(data); + + return ( + svg': { + outline: 'none', + }, + }} + > + + + + + + + + + + + + + { + const isFirst = payload.value === firstDate; + const isLast = payload.value === lastDate; + + if ((!isFirst && !isLast) || xTicks.length <= 1) { + return ; + } + + return ( + + {payload.value} + + ); + }} + padding={{ + left: 0, + right: 0, + }} + /> + + + + } + cursor={false} + isAnimationActive={false} + portal={tooltipPortal ?? undefined} + wrapperStyle={TOOLTIP_WRAPPER_STYLE} + /> + + { + const point = payload as SparklineChartPoint | undefined; + if (!Number.isFinite(cx) || !Number.isFinite(cy)) { + return ; + } + + const isError = Boolean(point?.error); + const hoverStroke = isError ? errorColor : color; + + return ( + + + + + ); + }} + dot={props => { + const { cx, cy, index, payload } = props; + const point = payload as SparklineChartPoint | undefined; + + if (point?.error) { + return ( + + ); + } + + if (index !== data.length - 1) { + return ; + } + + return ( + + ); + }} + /> + + + + + {legendItems && legendItems.length > 0 && ( + + )} + + ); +}; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/SparklineChart/SparklineLegend.tsx b/workspaces/scorecard/plugins/scorecard/src/components/SparklineChart/SparklineLegend.tsx new file mode 100644 index 00000000000..c7a6033e4b1 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/SparklineChart/SparklineLegend.tsx @@ -0,0 +1,74 @@ +/* + * 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 Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; + +import type { SparklineLegendItem } from '../../utils/sparklineLegend'; + +export type SparklineLegendProps = { + items: SparklineLegendItem[]; + testId?: string; +}; + +export const SparklineLegend = ({ items, testId }: SparklineLegendProps) => { + if (items.length === 0) { + return null; + } + + return ( + + {items.map(item => ( + + + + + + {item.label} + + + ))} + + ); +}; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/SparklineChart/SparklineTooltip.tsx b/workspaces/scorecard/plugins/scorecard/src/components/SparklineChart/SparklineTooltip.tsx new file mode 100644 index 00000000000..5bd6c8e1fc3 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/SparklineChart/SparklineTooltip.tsx @@ -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 Box from '@mui/material/Box'; +import { useTheme } from '@mui/material/styles'; + +import { formatWithMetricUnit } from '../../utils/formatMetricUnit'; +import type { SparklineChartPoint } from '../../utils/timeSeriesChartData'; + +export const SPARKLINE_TOOLTIP_SEPARATOR = ' · '; + +export const getSparklineTooltipLabel = ( + point: SparklineChartPoint, + unit?: string, +): string => { + if (point.error) { + return `${point.error}${SPARKLINE_TOOLTIP_SEPARATOR}${point.date}`; + } + return `${formatWithMetricUnit( + String(point.value), + unit, + )}${SPARKLINE_TOOLTIP_SEPARATOR}${point.date}`; +}; + +export const SparklineTooltip = ({ + active, + payload, + unit, +}: { + active?: boolean; + payload?: ReadonlyArray<{ payload?: SparklineChartPoint }>; + unit?: string; +}) => { + const theme = useTheme(); + const point = payload?.[0]?.payload as SparklineChartPoint | undefined; + + if (!active || !point) { + return null; + } + + return ( + + {getSparklineTooltipLabel(point, unit)} + + ); +}; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/SparklineChart/__tests__/SparklineChart.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/SparklineChart/__tests__/SparklineChart.test.tsx new file mode 100644 index 00000000000..6fd2162196c --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/SparklineChart/__tests__/SparklineChart.test.tsx @@ -0,0 +1,105 @@ +/* + * 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 { render, screen } from '@testing-library/react'; + +import { SparklineChart } from '../SparklineChart'; +import type { SparklineChartPoint } from '../../../utils/timeSeriesChartData'; + +jest.mock('recharts', () => { + const actual = jest.requireActual('recharts'); + return { + ...actual, + ResponsiveContainer: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + }; +}); + +const points: SparklineChartPoint[] = [ + { date: 'Apr 27', value: 18, plotValue: 18 }, + { date: 'Apr 30', value: 22, plotValue: 22 }, +]; + +describe('SparklineChart', () => { + it('renders the chart without a legend when no label is provided', () => { + render( + , + ); + + expect(screen.getByTestId('sparkline-chart-demo')).toBeInTheDocument(); + expect(screen.getByTestId('responsive-container')).toBeInTheDocument(); + expect(screen.getByTestId('sparkline-tooltip-slot')).toBeInTheDocument(); + expect( + screen.queryByTestId('sparkline-threshold-color'), + ).not.toBeInTheDocument(); + }); + + it('renders the threshold legend when items are provided', () => { + render( + 15%)', color: '#d32f2f' }]} + legendTestId="sparkline-threshold-legend-demo" + />, + ); + + expect(screen.getByText('Low (>15%)')).toBeInTheDocument(); + expect( + screen.getByTestId('sparkline-threshold-legend-demo'), + ).toBeInTheDocument(); + expect(screen.getByTestId('sparkline-threshold-color')).toHaveAttribute( + 'stroke', + '#d32f2f', + ); + }); + + it('renders every legend item when multiple thresholds are provided', () => { + render( + =7/week)', + color: '#2e7d32', + strokeDasharray: '10 7', + }, + { + key: 'medium', + label: 'Medium (1-7/week)', + color: '#F0AB00', + strokeDasharray: '2 4', + }, + { key: 'low', label: 'Low (<1/week)', color: '#C9190B' }, + ]} + legendTestId="sparkline-threshold-legend-demo" + />, + ); + + expect(screen.getByText('Elite (>=7/week)')).toBeInTheDocument(); + expect(screen.getByText('Medium (1-7/week)')).toBeInTheDocument(); + expect(screen.getByText('Low (<1/week)')).toBeInTheDocument(); + expect(screen.getAllByTestId('sparkline-threshold-color')).toHaveLength(3); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/SparklineChart/__tests__/SparklineTooltip.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/SparklineChart/__tests__/SparklineTooltip.test.tsx new file mode 100644 index 00000000000..8c80b6eea69 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/SparklineChart/__tests__/SparklineTooltip.test.tsx @@ -0,0 +1,80 @@ +/* + * 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 { render, screen } from '@testing-library/react'; +import { ThemeProvider, createTheme } from '@mui/material/styles'; + +import { + getSparklineTooltipLabel, + SparklineTooltip, +} from '../SparklineTooltip'; +import type { SparklineChartPoint } from '../../../utils/timeSeriesChartData'; + +const point: SparklineChartPoint = { + date: 'Aug 15', + value: 2.1, + plotValue: 2.1, +}; + +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe('getSparklineTooltipLabel', () => { + it('formats value and date with a middle dot', () => { + expect(getSparklineTooltipLabel(point)).toBe('2.1 · Aug 15'); + }); + + it('appends the metric unit to the value', () => { + expect(getSparklineTooltipLabel(point, '/week')).toBe('2.1/week · Aug 15'); + }); + + it('uses the error message when the point has no value', () => { + expect( + getSparklineTooltipLabel({ + date: 'Aug 15', + value: null, + plotValue: 0, + error: 'No data', + }), + ).toBe('No data · Aug 15'); + }); +}); + +describe('SparklineTooltip', () => { + it('does not render when inactive', () => { + const { container } = render( + , + { wrapper: TestWrapper }, + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it('renders the hovered point value and date', () => { + render( + , + { wrapper: TestWrapper }, + ); + + const tooltip = screen.getByTestId('sparkline-hover-tooltip'); + expect(tooltip).toHaveTextContent('2.1/week · Aug 15'); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/SparklineChart/index.ts b/workspaces/scorecard/plugins/scorecard/src/components/SparklineChart/index.ts new file mode 100644 index 00000000000..f2d10a823cf --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/SparklineChart/index.ts @@ -0,0 +1,21 @@ +/* + * 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 { SparklineChart } from './SparklineChart'; +export type { SparklineChartProps } from './SparklineChart'; +export { SparklineLegend } from './SparklineLegend'; +export type { SparklineLegendProps } from './SparklineLegend'; +export type { SparklineLegendItem } from '../../utils/sparklineLegend'; diff --git a/workspaces/scorecard/plugins/scorecard/src/hooks/__tests__/useMetricCollectors.test.tsx b/workspaces/scorecard/plugins/scorecard/src/hooks/__tests__/useMetricCollectors.test.tsx new file mode 100644 index 00000000000..0df5bd7a671 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/hooks/__tests__/useMetricCollectors.test.tsx @@ -0,0 +1,152 @@ +/* + * 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 { renderHook } from '@testing-library/react'; +import { useApi } from '@backstage/core-plugin-api'; +import { useQuery } from '@tanstack/react-query'; +import type { CollectorMetadata } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +import { useMetricCollectors } from '../useMetricCollectors'; + +jest.mock('@backstage/core-plugin-api'); +jest.mock('@tanstack/react-query', () => ({ + ...jest.requireActual('@tanstack/react-query'), + useQuery: jest.fn(), +})); + +const mockUseApi = useApi as jest.MockedFunction; +const mockUseQuery = useQuery as jest.MockedFunction; + +describe('useMetricCollectors', () => { + const mockScorecardApi = { + getMetricCollectors: jest.fn(), + }; + + const collectors: CollectorMetadata[] = [ + { + id: 'github:deploymentWorkflowRuns', + description: 'Collects deployments from GitHub Actions.', + }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseApi.mockReturnValue(mockScorecardApi); + }); + + it('should return collectors when the query succeeds', () => { + mockUseQuery.mockReturnValue({ + isLoading: false, + error: null, + data: collectors, + } as any); + + const { result } = renderHook(() => + useMetricCollectors('dora.changeFailureRate', true), + ); + + expect(result.current).toEqual({ + data: collectors, + isLoading: false, + error: undefined, + }); + }); + + it('should call useQuery with the metric id and enabled flag', () => { + mockUseQuery.mockReturnValue({ + isLoading: false, + error: null, + data: undefined, + } as any); + + renderHook(() => useMetricCollectors('dora.changeFailureRate', true)); + + expect(mockUseQuery).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ['metricCollectors', 'dora.changeFailureRate'], + enabled: true, + }), + ); + }); + + it('should disable the query when enabled is false', () => { + mockUseQuery.mockReturnValue({ + isLoading: false, + error: null, + data: undefined, + } as any); + + renderHook(() => useMetricCollectors('dora.changeFailureRate', false)); + + expect(mockUseQuery).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: false, + }), + ); + }); + + it('should disable the query when metric id is empty', () => { + mockUseQuery.mockReturnValue({ + isLoading: false, + error: null, + data: undefined, + } as any); + + renderHook(() => useMetricCollectors('', true)); + + expect(mockUseQuery).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: false, + }), + ); + }); + + it('should fetch collectors for the given metric id when enabled', async () => { + mockScorecardApi.getMetricCollectors.mockResolvedValue(collectors); + mockUseQuery.mockReturnValue({ + isLoading: false, + error: null, + data: undefined, + } as any); + + renderHook(() => useMetricCollectors('dora.changeFailureRate', true)); + + const queryFn = mockUseQuery.mock.calls[0][0] + .queryFn as () => Promise; + await expect(queryFn()).resolves.toEqual(collectors); + expect(mockScorecardApi.getMetricCollectors).toHaveBeenCalledWith( + 'dora.changeFailureRate', + ); + }); + + it('should hide loading and error when the query is disabled', () => { + mockUseQuery.mockReturnValue({ + isLoading: true, + error: new Error('collectors unavailable'), + data: undefined, + } as any); + + const { result } = renderHook(() => + useMetricCollectors('dora.changeFailureRate', false), + ); + + expect(result.current).toEqual({ + data: undefined, + isLoading: false, + error: undefined, + }); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/hooks/__tests__/useMetricTimeSeries.test.tsx b/workspaces/scorecard/plugins/scorecard/src/hooks/__tests__/useMetricTimeSeries.test.tsx new file mode 100644 index 00000000000..abf2ae1a6d1 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/hooks/__tests__/useMetricTimeSeries.test.tsx @@ -0,0 +1,188 @@ +/* + * 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 { renderHook } from '@testing-library/react'; +import { useApi } from '@backstage/core-plugin-api'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { useQuery } from '@tanstack/react-query'; +import type { MetricTimeSeriesResponse } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +import { TIME_SERIES_DEFAULT_RANGE_DAYS } from '../../utils/constants'; +import { useMetricTimeSeries } from '../useMetricTimeSeries'; + +jest.mock('@backstage/plugin-catalog-react'); +jest.mock('@backstage/core-plugin-api'); +jest.mock('@tanstack/react-query', () => ({ + ...jest.requireActual('@tanstack/react-query'), + useQuery: jest.fn(), +})); +jest.mock('../useTranslation', () => ({ + useTranslation: jest.fn().mockReturnValue({ + t: (key: string, opts?: { error?: string }) => + key === 'errors.fetchError' && opts?.error !== undefined + ? `fetch:${opts.error}` + : key, + }), +})); + +const mockUseEntity = useEntity as jest.MockedFunction; +const mockUseApi = useApi as jest.MockedFunction; +const mockUseQuery = useQuery as jest.MockedFunction; + +describe('useMetricTimeSeries', () => { + const mockScorecardApi = { + getMetricTimeSeries: jest.fn(), + }; + + const mockEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + namespace: 'default', + name: 'dora-scorecard', + }, + }; + + const timeSeries: MetricTimeSeriesResponse = { + metricId: 'dora.deploymentFrequency', + entityRef: 'component:default/dora-scorecard', + points: [{ value: 8, timestamp: '2026-04-27T23:10:00.000Z' }], + metadata: { + title: 'DORA - Deployment Frequency', + description: 'How often we deploy', + type: 'number', + history: true, + defaultVisualization: 'sparkline', + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseEntity.mockReturnValue({ entity: { ...mockEntity } }); + mockUseApi.mockReturnValue(mockScorecardApi); + }); + + it('should return time series data when the query succeeds', () => { + mockUseQuery.mockReturnValue({ + isLoading: false, + error: null, + data: timeSeries, + } as any); + + const { result } = renderHook(() => + useMetricTimeSeries('dora.deploymentFrequency'), + ); + + expect(result.current).toEqual({ + data: timeSeries, + isLoading: false, + error: undefined, + }); + }); + + it('should call useQuery with entity, metric, and range in the queryKey', () => { + mockUseQuery.mockReturnValue({ + isLoading: false, + error: null, + data: undefined, + } as any); + + renderHook(() => useMetricTimeSeries('dora.deploymentFrequency')); + + expect(mockUseQuery).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: [ + 'metricTimeSeries', + 'component:default/dora-scorecard', + 'dora.deploymentFrequency', + TIME_SERIES_DEFAULT_RANGE_DAYS, + ], + enabled: true, + }), + ); + }); + + it('should disable the query when metric id is empty', () => { + mockUseQuery.mockReturnValue({ + isLoading: false, + error: null, + data: undefined, + } as any); + + renderHook(() => useMetricTimeSeries('')); + + expect(mockUseQuery).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: false, + }), + ); + }); + + it('should call getMetricTimeSeries with entity, metric id, and a 30-day range', async () => { + mockScorecardApi.getMetricTimeSeries.mockResolvedValue(timeSeries); + mockUseQuery.mockReturnValue({ + isLoading: false, + error: null, + data: undefined, + } as any); + + renderHook(() => useMetricTimeSeries('dora.deploymentFrequency')); + + const queryFn = mockUseQuery.mock.calls[0][0] + .queryFn as () => Promise; + await queryFn(); + + expect(mockScorecardApi.getMetricTimeSeries).toHaveBeenCalledWith({ + entity: mockEntity, + metricId: 'dora.deploymentFrequency', + from: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/), + to: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/), + }); + }); + + it('should wrap non-Error rejections with translated fetch error', async () => { + mockScorecardApi.getMetricTimeSeries.mockRejectedValue(503); + mockUseQuery.mockReturnValue({ + isLoading: false, + error: null, + data: undefined, + } as any); + + renderHook(() => useMetricTimeSeries('dora.deploymentFrequency')); + + const queryFn = mockUseQuery.mock.calls[0][0] + .queryFn as () => Promise; + await expect(queryFn()).rejects.toThrow('fetch:503'); + }); + + it('should return loading state while fetching', () => { + mockUseQuery.mockReturnValue({ + isLoading: true, + error: null, + data: undefined, + } as any); + + const { result } = renderHook(() => + useMetricTimeSeries('dora.deploymentFrequency'), + ); + + expect(result.current).toEqual({ + data: undefined, + isLoading: true, + error: undefined, + }); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/hooks/useMetricCollectors.tsx b/workspaces/scorecard/plugins/scorecard/src/hooks/useMetricCollectors.tsx new file mode 100644 index 00000000000..7f3675193f4 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/hooks/useMetricCollectors.tsx @@ -0,0 +1,45 @@ +/* + * 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 { useApi } from '@backstage/core-plugin-api'; +import { useQuery } from '@tanstack/react-query'; +import type { CollectorMetadata } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +import { scorecardApiRef } from '../api'; +import { UseResponseData } from './types'; + +/** + * Fetches collector metadata for a metric. Pass `enabled` so the request runs + * only when the data-sources dialog is open and the metric has collector IDs. + */ +export const useMetricCollectors = ( + metricId: string, + enabled: boolean, +): UseResponseData => { + const scorecardApi = useApi(scorecardApiRef); + + const { error, isLoading, data } = useQuery({ + queryKey: ['metricCollectors', metricId], + queryFn: () => scorecardApi.getMetricCollectors(metricId), + enabled: enabled && Boolean(metricId?.trim()), + }); + + return { + data, + isLoading: enabled && isLoading, + error: enabled ? error ?? undefined : undefined, + }; +}; diff --git a/workspaces/scorecard/plugins/scorecard/src/hooks/useMetricTimeSeries.tsx b/workspaces/scorecard/plugins/scorecard/src/hooks/useMetricTimeSeries.tsx new file mode 100644 index 00000000000..68b5103a9e2 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/hooks/useMetricTimeSeries.tsx @@ -0,0 +1,87 @@ +/* + * 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 { stringifyEntityRef } from '@backstage/catalog-model'; +import { useApi } from '@backstage/core-plugin-api'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { useQuery } from '@tanstack/react-query'; +import type { MetricTimeSeriesResponse } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +import { scorecardApiRef } from '../api'; +import { TIME_SERIES_DEFAULT_RANGE_DAYS } from '../utils/constants'; +import { getDefaultTimeSeriesRange } from '../utils/timeSeriesRange'; +import { useTranslation } from './useTranslation'; +import { UseResponseData } from './types'; + +/** + * Fetches the 30-day catalog time series for one metric on the current entity. + */ +export const useMetricTimeSeries = ( + metricId: string, +): UseResponseData => { + const { entity } = useEntity(); + const scorecardApi = useApi(scorecardApiRef); + const { t } = useTranslation(); + + const hasEntity = Boolean( + entity?.kind && entity?.metadata?.namespace && entity?.metadata?.name, + ); + const entityRef = hasEntity ? stringifyEntityRef(entity) : ''; + + const { error, isLoading, data } = useQuery({ + queryKey: [ + 'metricTimeSeries', + entityRef, + metricId, + TIME_SERIES_DEFAULT_RANGE_DAYS, + ], + queryFn: async () => { + if ( + !entity?.kind || + !entity?.metadata?.namespace || + !entity?.metadata?.name + ) { + throw new Error(t('errors.entityMissingProperties')); + } + + try { + const { from, to } = getDefaultTimeSeriesRange(); + return await scorecardApi.getMetricTimeSeries({ + entity, + metricId, + from, + to, + }); + } catch (err) { + if (err instanceof Error) { + throw err; + } + throw new Error( + t('errors.fetchError' as any, { + error: String(err), + }), + ); + } + }, + enabled: Boolean(metricId?.trim()) && hasEntity, + }); + + return { + data, + isLoading, + error: error ?? undefined, + }; +}; diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/de.ts b/workspaces/scorecard/plugins/scorecard/src/translations/de.ts index 5f4e42794f6..5c74a378630 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/de.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/de.ts @@ -30,6 +30,12 @@ const scorecardTranslationDe = createTranslationMessages({ 'dataSourcesDialog.unknownPlugin': 'Unbekannt', 'dataSourcesDialog.statusTooltip': 'Wert {{value}} entspricht Schwellenwert {{status}} {{expression}}', + 'dataSourcesDialog.collectorStatusTooltip': + 'Dieser Collector liefert nur Eingabedaten. Der DORA-Metrikwert wird aus Collectors berechnet und auf der Scorecard-Karte angezeigt.', + 'dataSourcesDialog.collectorEmptyValue': '--', + 'dataSourcesDialog.collectorUnavailableStatus': 'k. A.', + 'dataSourcesDialog.pluginGithub': 'GitHub', + 'dataSourcesDialog.pluginJira': 'Jira', 'dataSourcesDialog.columns.plugin': 'PLUGIN', 'dataSourcesDialog.columns.check': 'PRÜFUNG', 'dataSourcesDialog.columns.value': 'WERT', diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/es.ts b/workspaces/scorecard/plugins/scorecard/src/translations/es.ts index d22c65e8922..736078bdf2d 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/es.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/es.ts @@ -30,6 +30,12 @@ const scorecardTranslationEs = createTranslationMessages({ 'dataSourcesDialog.unknownPlugin': 'Desconocido', 'dataSourcesDialog.statusTooltip': 'Valor {{value}} coincide con umbral {{status}} {{expression}}', + 'dataSourcesDialog.collectorStatusTooltip': + 'Este recopilador proporciona solo datos de entrada. El valor de la métrica DORA se calcula a partir de los recopiladores y se muestra en la tarjeta de scorecard.', + 'dataSourcesDialog.collectorEmptyValue': '--', + 'dataSourcesDialog.collectorUnavailableStatus': 'N/D', + 'dataSourcesDialog.pluginGithub': 'GitHub', + 'dataSourcesDialog.pluginJira': 'Jira', 'dataSourcesDialog.columns.plugin': 'PLUGIN', 'dataSourcesDialog.columns.check': 'VERIFICACIÓN', 'dataSourcesDialog.columns.value': 'VALOR', diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts b/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts index e8482c2ba88..2767b63ee54 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts @@ -30,6 +30,12 @@ const scorecardTranslationFr = createTranslationMessages({ 'dataSourcesDialog.unknownPlugin': 'Inconnu', 'dataSourcesDialog.statusTooltip': 'Valeur {{value}} correspond au seuil {{status}} {{expression}}', + 'dataSourcesDialog.collectorStatusTooltip': + "Ce collecteur fournit uniquement des données d'entrée. La valeur de la métrique DORA est calculée à partir des collecteurs et affichée sur la carte scorecard.", + 'dataSourcesDialog.collectorEmptyValue': '--', + 'dataSourcesDialog.collectorUnavailableStatus': 'N/A', + 'dataSourcesDialog.pluginGithub': 'GitHub', + 'dataSourcesDialog.pluginJira': 'Jira', 'dataSourcesDialog.columns.plugin': 'PLUGIN', 'dataSourcesDialog.columns.check': 'VÉRIFICATION', 'dataSourcesDialog.columns.value': 'VALEUR', diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/it.ts b/workspaces/scorecard/plugins/scorecard/src/translations/it.ts index 2fc5fd70f6b..15a079800c4 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/it.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/it.ts @@ -30,6 +30,12 @@ const scorecardTranslationIt = createTranslationMessages({ 'dataSourcesDialog.unknownPlugin': 'Sconosciuto', 'dataSourcesDialog.statusTooltip': 'Valore {{value}} corrisponde alla soglia {{status}} {{expression}}', + 'dataSourcesDialog.collectorStatusTooltip': + 'Questo collector fornisce solo dati di input. Il valore della metrica DORA viene calcolato dai collector e mostrato sulla scheda scorecard.', + 'dataSourcesDialog.collectorEmptyValue': '--', + 'dataSourcesDialog.collectorUnavailableStatus': 'N/D', + 'dataSourcesDialog.pluginGithub': 'GitHub', + 'dataSourcesDialog.pluginJira': 'Jira', 'dataSourcesDialog.columns.plugin': 'PLUGIN', 'dataSourcesDialog.columns.check': 'VERIFICA', 'dataSourcesDialog.columns.value': 'VALORE', diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/ja.ts b/workspaces/scorecard/plugins/scorecard/src/translations/ja.ts index 9ad479ba8fc..6b967a6c164 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/ja.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/ja.ts @@ -30,6 +30,12 @@ const scorecardTranslationJa = createTranslationMessages({ 'dataSourcesDialog.unknownPlugin': '不明', 'dataSourcesDialog.statusTooltip': '値 {{value}} はしきい値 {{status}} {{expression}} に一致します', + 'dataSourcesDialog.collectorStatusTooltip': + 'このコレクターは入力データのみを提供します。DORA メトリック値はコレクターから計算され、スコアカードのカードに表示されます。', + 'dataSourcesDialog.collectorEmptyValue': '--', + 'dataSourcesDialog.collectorUnavailableStatus': '該当なし', + 'dataSourcesDialog.pluginGithub': 'GitHub', + 'dataSourcesDialog.pluginJira': 'Jira', 'dataSourcesDialog.columns.plugin': 'PLUGIN', 'dataSourcesDialog.columns.check': 'チェック', 'dataSourcesDialog.columns.value': '値', diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/ref.ts b/workspaces/scorecard/plugins/scorecard/src/translations/ref.ts index 5e4eb9281a8..b1debb1ecbf 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/ref.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/ref.ts @@ -216,6 +216,12 @@ export const scorecardMessages = { unknownPlugin: 'Unknown', statusTooltip: 'Value {{value}} matches threshold {{status}} {{expression}}', + collectorStatusTooltip: + 'This collector provides input data only. The DORA metric value is calculated from collectors and shown on the scorecard card.', + collectorEmptyValue: '--', + collectorUnavailableStatus: 'N/A', + pluginGithub: 'GitHub', + pluginJira: 'Jira', columns: { plugin: 'PLUGIN', check: 'CHECK', diff --git a/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/metricVisualization.test.ts b/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/metricVisualization.test.ts new file mode 100644 index 00000000000..53d341a7adc --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/metricVisualization.test.ts @@ -0,0 +1,28 @@ +/* + * 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 { isSparklineVisualization } from '../metricVisualization'; + +describe('isSparklineVisualization', () => { + it('should return true when visualization is sparkline', () => { + expect(isSparklineVisualization('sparkline')).toBe(true); + }); + + it('should return false for score donut metrics', () => { + expect(isSparklineVisualization()).toBe(false); + expect(isSparklineVisualization('donut')).toBe(false); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/sparklineChartModel.test.ts b/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/sparklineChartModel.test.ts new file mode 100644 index 00000000000..e19e401fa42 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/sparklineChartModel.test.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 { createTheme } from '@mui/material/styles'; + +import { toSparklineChartModel } from '../sparklineChartModel'; +import { SPARKLINE_DASHED_STROKE } from '../sparklineLegend'; +import { mockT } from '../../test-utils/mockTranslations'; + +const t = mockT as Parameters[0]['t']; +const theme = createTheme({ + palette: { + success: { main: '#2e7d32' }, + warning: { main: '#F0AB00' }, + error: { main: '#C9190B' }, + }, +}); + +describe('toSparklineChartModel', () => { + it('should map chart data, line style, and all-rule legend items', () => { + const model = toSparklineChartModel({ + inputPoints: [ + { value: 10, timestamp: '2026-08-23T00:00:00.000Z' }, + { value: 8, timestamp: '2026-08-24T00:00:00.000Z' }, + ], + formatDateLabel: timestamp => timestamp.slice(5, 10), + matchingThresholdKey: 'elite', + chartColor: '#2e7d32', + unit: '/week', + theme, + t, + legendRules: [ + { key: 'elite', expression: '>=7', color: 'success.main' }, + { key: 'medium', expression: '1-7', color: 'warning.main' }, + ], + }); + + expect(model.chartColor).toBe('#2e7d32'); + expect(model.strokeDasharray).toBe(SPARKLINE_DASHED_STROKE); + expect(model.chartData).toHaveLength(2); + expect(model.legendItems.map(item => item.key)).toEqual([ + 'elite', + 'medium', + ]); + }); + + it('should limit the legend to the matched rule when only that rule is passed', () => { + const model = toSparklineChartModel({ + inputPoints: [{ value: 22, timestamp: '2026-08-23T00:00:00.000Z' }], + formatDateLabel: timestamp => timestamp.slice(5, 10), + matchingThresholdKey: 'low', + chartColor: '#C9190B', + unit: '%', + theme, + t, + legendRules: [{ key: 'low', expression: '>15', color: 'error.main' }], + }); + + expect(model.legendItems).toHaveLength(1); + expect(model.legendItems[0].key).toBe('low'); + expect(model.strokeDasharray).toBeUndefined(); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/sparklineLegend.test.ts b/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/sparklineLegend.test.ts new file mode 100644 index 00000000000..eb0fca05437 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/sparklineLegend.test.ts @@ -0,0 +1,72 @@ +/* + * 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 { + formatThresholdLegendLabel, + getSparklineLineStyle, + SPARKLINE_DASHED_STROKE, + SPARKLINE_DOTTED_STROKE, +} from '../sparklineLegend'; +import { mockT } from '../../test-utils/mockTranslations'; + +const t = mockT as Parameters[1]; + +describe('formatThresholdLegendLabel', () => { + it('should append the unit to a numeric expression', () => { + expect( + formatThresholdLegendLabel( + { key: 'elite', expression: '>=7' }, + t, + '/week', + ), + ).toBe('Elite (>=7/week)'); + }); + + it('should omit boolean expressions', () => { + expect( + formatThresholdLegendLabel({ key: 'success', expression: '==true' }, t), + ).toBe('Success'); + }); +}); + +describe('getSparklineLineStyle', () => { + it('should dash elite and success, dot medium and warning, and leave low/error solid', () => { + expect(getSparklineLineStyle('elite')).toEqual({ + strokeDasharray: SPARKLINE_DASHED_STROKE, + }); + expect(getSparklineLineStyle('success')).toEqual({ + strokeDasharray: SPARKLINE_DASHED_STROKE, + }); + expect(getSparklineLineStyle('medium')).toEqual({ + strokeDasharray: SPARKLINE_DOTTED_STROKE, + }); + expect(getSparklineLineStyle('warning')).toEqual({ + strokeDasharray: SPARKLINE_DOTTED_STROKE, + }); + expect(getSparklineLineStyle('low')).toEqual({}); + expect(getSparklineLineStyle('error')).toEqual({}); + }); + + it('should fall back to index for unknown keys', () => { + expect(getSparklineLineStyle('custom', 0)).toEqual({ + strokeDasharray: SPARKLINE_DASHED_STROKE, + }); + expect(getSparklineLineStyle('custom', 1)).toEqual({ + strokeDasharray: SPARKLINE_DOTTED_STROKE, + }); + expect(getSparklineLineStyle('custom', 2)).toEqual({}); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/timeSeriesChartData.test.ts b/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/timeSeriesChartData.test.ts new file mode 100644 index 00000000000..89bb92e379b --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/timeSeriesChartData.test.ts @@ -0,0 +1,208 @@ +/* + * 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 { + formatAggregatedTimeSeriesErrors, + getSparklineYDomain, + toAggregationSparklinePoints, + toMetricSparklinePoints, + toSparklineChartData, +} from '../timeSeriesChartData'; + +describe('toSparklineChartData', () => { + const formatDateLabel = (timestamp: string) => timestamp.slice(5, 10); + + it('should map numeric points and interpolates error days onto the line', () => { + const result = toSparklineChartData( + [ + { value: 2, timestamp: '2026-04-27T00:00:00.000Z' }, + { + value: null, + timestamp: '2026-04-28T00:00:00.000Z', + error: 'GitHub API 500', + }, + { value: 8, timestamp: '2026-04-29T00:00:00.000Z' }, + ], + formatDateLabel, + ); + + expect(result).toEqual([ + { date: '04-27', value: 2, error: undefined, plotValue: 2 }, + { + date: '04-28', + value: null, + error: 'GitHub API 500', + plotValue: 5, + }, + { date: '04-29', value: 8, error: undefined, plotValue: 8 }, + ]); + }); + + it('should skip boolean values when building the series', () => { + const result = toSparklineChartData( + [{ value: true, timestamp: '2026-04-27T00:00:00.000Z' }], + formatDateLabel, + ); + + expect(result[0].value).toBeNull(); + expect(result[0].plotValue).toBe(0); + }); +}); + +describe('formatAggregatedTimeSeriesErrors', () => { + it('should join unique error messages and append counts greater than one', () => { + expect( + formatAggregatedTimeSeriesErrors([ + { message: 'timeout', count: 1 }, + { message: 'GitHub API 500', count: 2 }, + ]), + ).toBe('timeout; GitHub API 500 (2)'); + }); + + it('should return undefined when there are no errors', () => { + expect(formatAggregatedTimeSeriesErrors()).toBeUndefined(); + expect(formatAggregatedTimeSeriesErrors([])).toBeUndefined(); + }); +}); + +describe('toMetricSparklinePoints', () => { + it('should map successful points without an error tooltip', () => { + expect( + toMetricSparklinePoints( + [{ value: 8, timestamp: '2026-04-27T00:00:00.000Z' }], + 'Unavailable', + ), + ).toEqual([ + { + value: 8, + timestamp: '2026-04-27T00:00:00.000Z', + error: undefined, + }, + ]); + }); + + it('should keep the calculation-failure message on error points', () => { + expect( + toMetricSparklinePoints( + [ + { + value: null, + timestamp: '2026-04-28T00:00:00.000Z', + error: 'GitHub API 500', + }, + ], + 'Unavailable', + ), + ).toEqual([ + { + value: null, + timestamp: '2026-04-28T00:00:00.000Z', + error: 'GitHub API 500', + }, + ]); + }); + + it('should use the fallback label when value is null and no error is set', () => { + expect( + toMetricSparklinePoints( + [{ value: null, timestamp: '2026-04-29T00:00:00.000Z' }], + 'Unavailable', + ), + ).toEqual([ + { + value: null, + timestamp: '2026-04-29T00:00:00.000Z', + error: 'Unavailable', + }, + ]); + }); +}); + +describe('toAggregationSparklinePoints', () => { + it('should map successful points without an error tooltip', () => { + expect( + toAggregationSparklinePoints( + [ + { + value: 10, + successCount: 5, + errorCount: 0, + total: 5, + status: 'success', + timestamp: '2026-08-23T00:00:00.000Z', + }, + ], + 'Unavailable', + ), + ).toEqual([ + { + value: 10, + timestamp: '2026-08-23T00:00:00.000Z', + error: undefined, + }, + ]); + }); + + it('should use joined error messages or the fallback label on error days', () => { + expect( + toAggregationSparklinePoints( + [ + { + value: null, + successCount: 0, + errorCount: 2, + total: 2, + status: 'error', + errors: [{ message: 'timeout', count: 2 }], + timestamp: '2026-08-24T00:00:00.000Z', + }, + { + value: null, + successCount: 0, + errorCount: 1, + total: 1, + status: 'error', + timestamp: '2026-08-25T00:00:00.000Z', + }, + ], + 'Unavailable', + ), + ).toEqual([ + { + value: null, + timestamp: '2026-08-24T00:00:00.000Z', + error: 'timeout (2)', + }, + { + value: null, + timestamp: '2026-08-25T00:00:00.000Z', + error: 'Unavailable', + }, + ]); + }); +}); + +describe('getSparklineYDomain', () => { + it('should pad a single-value series', () => { + expect( + getSparklineYDomain([{ date: 'Apr 27', value: 5, plotValue: 5 }]), + ).toEqual([4.5, 5.5]); + }); + + it('should return a fallback domain for empty data', () => { + expect(getSparklineYDomain([])).toEqual([0, 1]); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/timeSeriesRange.test.ts b/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/timeSeriesRange.test.ts new file mode 100644 index 00000000000..45525290018 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/timeSeriesRange.test.ts @@ -0,0 +1,31 @@ +/* + * 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 { TIME_SERIES_DEFAULT_RANGE_DAYS } from '../constants'; +import { getDefaultTimeSeriesRange } from '../timeSeriesRange'; + +describe('getDefaultTimeSeriesRange', () => { + it('should return an inclusive ISO-8601 window of 30 days', () => { + const now = new Date('2026-04-30T12:00:00.000Z'); + const { from, to } = getDefaultTimeSeriesRange(now); + + expect(to).toBe('2026-04-30T12:00:00.000Z'); + expect(from).toBe('2026-03-31T12:00:00.000Z'); + expect(new Date(to).getTime() - new Date(from).getTime()).toBe( + TIME_SERIES_DEFAULT_RANGE_DAYS * 24 * 60 * 60 * 1000, + ); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/translationUtils.test.ts b/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/translationUtils.test.ts index 8e3989c42ab..aa0ee12aa95 100644 --- a/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/translationUtils.test.ts +++ b/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/translationUtils.test.ts @@ -14,7 +14,10 @@ * limitations under the License. */ -import { resolveMetricTranslation } from '../translationUtils'; +import { + resolveMetricTranslation, + extractPluginName, +} from '../translationUtils'; type MockT = (key: string, params?: Record) => string; @@ -174,3 +177,20 @@ describe('resolveMetricTranslation', () => { ).toBe('File check: readme'); }); }); + +describe('extractPluginName', () => { + it('should use the first segment of a dotted metric id', () => { + expect(extractPluginName('github.openPRs', 'Unknown')).toBe('Github'); + }); + + it('should use the first segment of a collector id', () => { + expect(extractPluginName('github:deploymentWorkflowRuns', 'Unknown')).toBe( + 'Github', + ); + expect(extractPluginName('jira:incidents', 'Unknown')).toBe('Jira'); + }); + + it('should return the fallback when the id is missing', () => { + expect(extractPluginName(undefined, 'Unknown')).toBe('Unknown'); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/utils/constants.ts b/workspaces/scorecard/plugins/scorecard/src/utils/constants.ts index 1e4aaa976e6..cf6b49160b3 100644 --- a/workspaces/scorecard/plugins/scorecard/src/utils/constants.ts +++ b/workspaces/scorecard/plugins/scorecard/src/utils/constants.ts @@ -19,6 +19,9 @@ */ export const SCORECARD_ERROR_STATE_COLOR = 'rhdh.general.cardBorderColor'; +/** Default lookback period for sparkline data. */ +export const TIME_SERIES_DEFAULT_RANGE_DAYS = 30; + interface HeadCell { id: string; label: string; diff --git a/workspaces/scorecard/plugins/scorecard/src/utils/index.ts b/workspaces/scorecard/plugins/scorecard/src/utils/index.ts index e8552d2db78..940c3f3e6b0 100644 --- a/workspaces/scorecard/plugins/scorecard/src/utils/index.ts +++ b/workspaces/scorecard/plugins/scorecard/src/utils/index.ts @@ -21,9 +21,23 @@ export { export { SCORECARD_ENTITIES_TABLE_HEADERS, SCORECARD_ERROR_STATE_COLOR, + TIME_SERIES_DEFAULT_RANGE_DAYS, } from './constants'; export { getLastUpdatedLabel } from './entityTableUtils'; export { formatWithMetricUnit } from './formatMetricUnit'; +export { getDefaultTimeSeriesRange } from './timeSeriesRange'; +export { + formatAggregatedTimeSeriesErrors, + getSparklineYDomain, + toAggregationSparklinePoints, + toMetricSparklinePoints, + toSparklineChartData, +} from './timeSeriesChartData'; +export type { + SparklineChartPoint, + TimeSeriesChartInputPoint, +} from './timeSeriesChartData'; +export { isSparklineVisualization } from './metricVisualization'; export { isDistributionAggregationResult, isScalarAggregationResult, @@ -40,6 +54,16 @@ export { resolveStatusColor, } from './statusUtils'; export { getThresholdRuleColor, getThresholdRuleIcon } from './thresholdUtils'; +export { + formatThresholdLegendLabel, + getSparklineLineStyle, + toSparklineLegendItems, + SPARKLINE_DASHED_STROKE, + SPARKLINE_DOTTED_STROKE, +} from './sparklineLegend'; +export type { SparklineLegendItem } from './sparklineLegend'; +export { toSparklineChartModel } from './sparklineChartModel'; +export type { SparklineChartModel } from './sparklineChartModel'; export { resolveMetricTranslation, extractPluginName, diff --git a/workspaces/scorecard/plugins/scorecard/src/utils/metricVisualization.ts b/workspaces/scorecard/plugins/scorecard/src/utils/metricVisualization.ts new file mode 100644 index 00000000000..70e3872869c --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/utils/metricVisualization.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. + */ + +import type { ScorecardVisualizationType } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +/** + * Returns true if the visualization should be displayed as a sparkline. + * + * The visualization comes from the metric provider and is stored + * in the aggregation metadata. + */ +export const isSparklineVisualization = ( + visualization?: ScorecardVisualizationType, +): boolean => visualization === 'sparkline'; diff --git a/workspaces/scorecard/plugins/scorecard/src/utils/sparklineChartModel.ts b/workspaces/scorecard/plugins/scorecard/src/utils/sparklineChartModel.ts new file mode 100644 index 00000000000..063c8ce9ffe --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/utils/sparklineChartModel.ts @@ -0,0 +1,73 @@ +/* + * 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 type { Theme } from '@mui/material/styles'; +import type { TranslationFunction } from '@backstage/core-plugin-api/alpha'; +import type { ThresholdRule } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +import { scorecardTranslationRef } from '../translations'; +import { + getSparklineLineStyle, + toSparklineLegendItems, + type SparklineLegendItem, +} from './sparklineLegend'; +import { + toSparklineChartData, + type SparklineChartPoint, + type TimeSeriesChartInputPoint, +} from './timeSeriesChartData'; + +export type SparklineChartModel = { + chartData: SparklineChartPoint[]; + chartColor: string; + strokeDasharray?: string; + legendItems: SparklineLegendItem[]; +}; + +/** + * Shared sparkline view-model for entity and homepage cards. + * Callers map API points first, then pass already-resolved chart color + * and which threshold rules to show in the legend (`all` vs matched). + */ +export const toSparklineChartModel = ({ + inputPoints, + formatDateLabel, + matchingThresholdKey, + chartColor, + unit, + theme, + t, + legendRules, +}: { + inputPoints: TimeSeriesChartInputPoint[]; + formatDateLabel: (timestamp: string) => string; + matchingThresholdKey?: string; + chartColor: string; + unit?: string; + theme: Theme; + t: TranslationFunction; + legendRules?: ThresholdRule[]; +}): SparklineChartModel => ({ + chartData: toSparklineChartData(inputPoints, formatDateLabel), + chartColor, + strokeDasharray: getSparklineLineStyle(matchingThresholdKey).strokeDasharray, + legendItems: toSparklineLegendItems({ + rules: legendRules, + theme, + t, + unit, + }), +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/utils/sparklineLegend.ts b/workspaces/scorecard/plugins/scorecard/src/utils/sparklineLegend.ts new file mode 100644 index 00000000000..52a158f6623 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/utils/sparklineLegend.ts @@ -0,0 +1,103 @@ +/* + * 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 type { Theme } from '@mui/material/styles'; +import type { TranslationFunction } from '@backstage/core-plugin-api/alpha'; +import type { ThresholdRule } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +import { scorecardTranslationRef } from '../translations'; +import { SCORECARD_ERROR_STATE_COLOR } from './constants'; +import { formatWithMetricUnit } from './formatMetricUnit'; +import { getTranslatedStatus, resolveStatusColor } from './statusUtils'; +import { getThresholdRuleColor } from './thresholdUtils'; + +export type SparklineLegendItem = { + key: string; + color: string; + label: string; + strokeDasharray?: string; +}; + +export const SPARKLINE_DASHED_STROKE = '10 7'; +export const SPARKLINE_DOTTED_STROKE = '4 4'; + +const BOOLEAN_EXPRESSION = /^==(?:true|false)$/; + +export const formatThresholdLegendLabel = ( + rule: Pick, + t: TranslationFunction, + unit?: string, +): string => { + const name = getTranslatedStatus(rule.key, t); + if (!rule.expression || BOOLEAN_EXPRESSION.test(rule.expression)) { + return name; + } + return `${name} (${formatWithMetricUnit(rule.expression, unit)})`; +}; + +/** + * Line style for a threshold band. Elite/success are dashed, medium/warning + * are dotted, and low/error are solid — matching the homepage legend. + */ +export const getSparklineLineStyle = ( + thresholdKey?: string, + index = 0, +): { strokeDasharray?: string } => { + const key = thresholdKey?.toLowerCase(); + if (key === 'elite' || key === 'success') { + return { strokeDasharray: SPARKLINE_DASHED_STROKE }; + } + if (key === 'medium' || key === 'warning') { + return { strokeDasharray: SPARKLINE_DOTTED_STROKE }; + } + if (key === 'low' || key === 'error') { + return {}; + } + if (index === 0) { + return { strokeDasharray: SPARKLINE_DASHED_STROKE }; + } + if (index === 1) { + return { strokeDasharray: SPARKLINE_DOTTED_STROKE }; + } + return {}; +}; + +export const toSparklineLegendItems = ({ + rules, + theme, + t, + unit, +}: { + rules?: ThresholdRule[]; + theme: Theme; + t: TranslationFunction; + unit?: string; +}): SparklineLegendItem[] => { + if (!rules?.length) { + return []; + } + + return rules.map((rule, index) => { + const colorToken = + getThresholdRuleColor(rules, rule.key) ?? SCORECARD_ERROR_STATE_COLOR; + return { + key: rule.key, + label: formatThresholdLegendLabel(rule, t, unit), + color: resolveStatusColor(theme, colorToken), + strokeDasharray: getSparklineLineStyle(rule.key, index).strokeDasharray, + }; + }); +}; diff --git a/workspaces/scorecard/plugins/scorecard/src/utils/timeSeriesChartData.ts b/workspaces/scorecard/plugins/scorecard/src/utils/timeSeriesChartData.ts new file mode 100644 index 00000000000..21c8ef0a499 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/utils/timeSeriesChartData.ts @@ -0,0 +1,168 @@ +/* + * 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 type { + MetricTimeSeriesPoint, + ScalarAggregatedTimeSeriesPoint, + TimeSeriesPointError, +} from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +export type SparklineChartPoint = { + date: string; + value: number | null; + error?: string; + plotValue: number; +}; + +export type TimeSeriesChartInputPoint = { + value: MetricTimeSeriesPoint['value'] | null; + timestamp: string; + error?: string; +}; + +const toNumericValue = ( + value: TimeSeriesChartInputPoint['value'], +): number | null => + typeof value === 'number' && Number.isFinite(value) ? value : null; + +const interpolatePlotValue = ( + points: Array>, + index: number, +): number => { + const current = points[index].value; + if (current !== null) { + return current; + } + + let prevIndex = -1; + for (let i = index - 1; i >= 0; i -= 1) { + if (points[i].value !== null) { + prevIndex = i; + break; + } + } + + let nextIndex = -1; + for (let i = index + 1; i < points.length; i += 1) { + if (points[i].value !== null) { + nextIndex = i; + break; + } + } + + if (prevIndex !== -1 && nextIndex !== -1) { + const prev = points[prevIndex].value as number; + const next = points[nextIndex].value as number; + const t = (index - prevIndex) / (nextIndex - prevIndex); + return prev + (next - prev) * t; + } + + if (prevIndex !== -1) { + return points[prevIndex].value as number; + } + + if (nextIndex !== -1) { + return points[nextIndex].value as number; + } + + return 0; +}; + +export const formatAggregatedTimeSeriesErrors = ( + errors?: TimeSeriesPointError[], +): string | undefined => { + if (!errors?.length) { + return undefined; + } + + return errors + .map(error => + error.count > 1 ? `${error.message} (${error.count})` : error.message, + ) + .join('; '); +}; + +/** + * Maps catalog-entity metric time-series points into sparkline input rows. + * Calculation failures keep a tooltip string so the chart can mark them. + */ +export const toMetricSparklinePoints = ( + points: MetricTimeSeriesPoint[], + fallbackErrorLabel: string, +): TimeSeriesChartInputPoint[] => + points.map(point => ({ + value: point.value, + timestamp: point.timestamp, + error: + point.error ?? (point.value === null ? fallbackErrorLabel : undefined), + })); + +/** + * Maps scalar aggregation time-series points into sparkline input rows. + * Error days keep a tooltip string so the chart can mark them. + */ +export const toAggregationSparklinePoints = ( + points: ScalarAggregatedTimeSeriesPoint[], + fallbackErrorLabel: string, +): TimeSeriesChartInputPoint[] => + points.map(point => ({ + value: point.value, + timestamp: point.timestamp, + error: + point.status === 'error' + ? formatAggregatedTimeSeriesErrors(point.errors) ?? fallbackErrorLabel + : undefined, + })); + +/** + * Maps API time-series points into chart rows. Error / null values keep their + * x position and get an interpolated Y so the sparkline stays continuous. + */ +export const toSparklineChartData = ( + points: TimeSeriesChartInputPoint[], + formatDateLabel: (timestamp: string) => string, +): SparklineChartPoint[] => { + const raw = points.map(point => ({ + date: formatDateLabel(point.timestamp), + value: toNumericValue(point.value), + error: point.error, + })); + + return raw.map((point, index) => ({ + ...point, + plotValue: interpolatePlotValue(raw, index), + })); +}; + +export const getSparklineYDomain = ( + points: SparklineChartPoint[], +): [number, number] => { + if (points.length === 0) { + return [0, 1]; + } + + const values = points.map(point => point.plotValue); + const min = Math.min(...values); + const max = Math.max(...values); + + if (min === max) { + const pad = Math.abs(min) * 0.1 || 1; + return [min - pad, max + pad]; + } + + const pad = (max - min) * 0.1; + return [min - pad, max + pad]; +}; diff --git a/workspaces/scorecard/plugins/scorecard/src/utils/timeSeriesRange.ts b/workspaces/scorecard/plugins/scorecard/src/utils/timeSeriesRange.ts new file mode 100644 index 00000000000..0c80d3b53e7 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/utils/timeSeriesRange.ts @@ -0,0 +1,33 @@ +/* + * 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 { TIME_SERIES_DEFAULT_RANGE_DAYS } from './constants'; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +/** + * Inclusive ISO-8601 range covering the last {@link TIME_SERIES_DEFAULT_RANGE_DAYS} days. + */ +export function getDefaultTimeSeriesRange(now: Date = new Date()): { + from: string; + to: string; +} { + const to = now; + const from = new Date( + to.getTime() - TIME_SERIES_DEFAULT_RANGE_DAYS * MS_PER_DAY, + ); + return { from: from.toISOString(), to: to.toISOString() }; +} diff --git a/workspaces/scorecard/plugins/scorecard/src/utils/translationUtils.ts b/workspaces/scorecard/plugins/scorecard/src/utils/translationUtils.ts index 019f50f33e1..02f831d0d3a 100644 --- a/workspaces/scorecard/plugins/scorecard/src/utils/translationUtils.ts +++ b/workspaces/scorecard/plugins/scorecard/src/utils/translationUtils.ts @@ -70,6 +70,6 @@ export function extractPluginName( fallback: string, ): string { if (!metricId) return fallback; - const prefix = metricId.split('.')[0] ?? metricId; + const prefix = metricId.split(/[.:]/)[0] ?? metricId; return prefix.charAt(0).toUpperCase() + prefix.slice(1); } From 3dd8c8bebb7118180511b6fa877d6840da9bf62e Mon Sep 17 00:00:00 2001 From: Eswaraiahsapram Date: Fri, 4 Sep 2026 14:21:29 +0530 Subject: [PATCH 2/3] fix spirkline chart --- .../.changeset/dora-sparkline-frontend.md | 5 +++ .../SparklineChart/SparklineChart.tsx | 40 ++++++++++++++----- .../SparklineChart/SparklineTooltip.tsx | 5 ++- .../__tests__/SparklineChart.test.tsx | 14 ++++++- .../__tests__/SparklineTooltip.test.tsx | 6 ++- .../__tests__/timeSeriesChartData.test.ts | 25 ++++++++++-- .../src/utils/timeSeriesChartData.ts | 6 ++- 7 files changed, 79 insertions(+), 22 deletions(-) create mode 100644 workspaces/scorecard/.changeset/dora-sparkline-frontend.md diff --git a/workspaces/scorecard/.changeset/dora-sparkline-frontend.md b/workspaces/scorecard/.changeset/dora-sparkline-frontend.md new file mode 100644 index 00000000000..119baf390ff --- /dev/null +++ b/workspaces/scorecard/.changeset/dora-sparkline-frontend.md @@ -0,0 +1,5 @@ +--- +'@red-hat-developer-hub/backstage-plugin-scorecard': minor +--- + +Add sparkline chart support for entity-page metrics whose `defaultVisualization` is `sparkline`. Renders a 30-day time-series area chart via `GET /metrics/catalog/:kind/:namespace/:name/time-series` and a "View data sources" dialog backed by `GET /metrics/:metricId/collectors`. Includes shared `SparklineChart` component, threshold legend, chart view-model utilities, and i18n for collector labels. diff --git a/workspaces/scorecard/plugins/scorecard/src/components/SparklineChart/SparklineChart.tsx b/workspaces/scorecard/plugins/scorecard/src/components/SparklineChart/SparklineChart.tsx index d3642b529c3..48fc21111e1 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/SparklineChart/SparklineChart.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/SparklineChart/SparklineChart.tsx @@ -77,14 +77,14 @@ export const SparklineChart = ({ const errorColor = theme.palette.error.main; const axisTickColor = theme.palette.text.secondary; const markerStroke = theme.palette.background.paper; - const firstDate = data[0]?.date; - const lastDate = data[data.length - 1]?.date; + const firstPoint = data[0]; + const lastPoint = data[data.length - 1]; const xTicks: string[] = []; - if (firstDate) { - xTicks.push(firstDate); + if (firstPoint) { + xTicks.push(firstPoint.date); } - if (lastDate && lastDate !== firstDate) { - xTicks.push(lastDate); + if (lastPoint && lastPoint.date !== firstPoint?.date) { + xTicks.push(lastPoint.date); } const yDomain = getSparklineYDomain(data); @@ -116,7 +116,7 @@ export const SparklineChart = ({ { - const isFirst = payload.value === firstDate; - const isLast = payload.value === lastDate; + const isFirst = payload.value === firstPoint?.date; + const isLast = payload.value === lastPoint?.date; - if ((!isFirst && !isLast) || xTicks.length <= 1) { + if (!isFirst && !isLast) { return ; } + const point = data.find(d => d.date === payload.value); + const label = point?.dateLabel ?? payload.value; + + if (xTicks.length <= 1) { + return ( + + {label} + + ); + } + return ( - {payload.value} + {label} ); }} diff --git a/workspaces/scorecard/plugins/scorecard/src/components/SparklineChart/SparklineTooltip.tsx b/workspaces/scorecard/plugins/scorecard/src/components/SparklineChart/SparklineTooltip.tsx index 5bd6c8e1fc3..9a2c3a11dd0 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/SparklineChart/SparklineTooltip.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/SparklineChart/SparklineTooltip.tsx @@ -26,13 +26,14 @@ export const getSparklineTooltipLabel = ( point: SparklineChartPoint, unit?: string, ): string => { + const label = point.dateLabel; if (point.error) { - return `${point.error}${SPARKLINE_TOOLTIP_SEPARATOR}${point.date}`; + return `${point.error}${SPARKLINE_TOOLTIP_SEPARATOR}${label}`; } return `${formatWithMetricUnit( String(point.value), unit, - )}${SPARKLINE_TOOLTIP_SEPARATOR}${point.date}`; + )}${SPARKLINE_TOOLTIP_SEPARATOR}${label}`; }; export const SparklineTooltip = ({ diff --git a/workspaces/scorecard/plugins/scorecard/src/components/SparklineChart/__tests__/SparklineChart.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/SparklineChart/__tests__/SparklineChart.test.tsx index 6fd2162196c..c00a5a1be99 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/SparklineChart/__tests__/SparklineChart.test.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/SparklineChart/__tests__/SparklineChart.test.tsx @@ -30,8 +30,18 @@ jest.mock('recharts', () => { }); const points: SparklineChartPoint[] = [ - { date: 'Apr 27', value: 18, plotValue: 18 }, - { date: 'Apr 30', value: 22, plotValue: 22 }, + { + date: '2026-04-27T00:00:00.000Z', + dateLabel: 'Apr 27', + value: 18, + plotValue: 18, + }, + { + date: '2026-04-30T00:00:00.000Z', + dateLabel: 'Apr 30', + value: 22, + plotValue: 22, + }, ]; describe('SparklineChart', () => { diff --git a/workspaces/scorecard/plugins/scorecard/src/components/SparklineChart/__tests__/SparklineTooltip.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/SparklineChart/__tests__/SparklineTooltip.test.tsx index 8c80b6eea69..d0534a611a8 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/SparklineChart/__tests__/SparklineTooltip.test.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/SparklineChart/__tests__/SparklineTooltip.test.tsx @@ -24,7 +24,8 @@ import { import type { SparklineChartPoint } from '../../../utils/timeSeriesChartData'; const point: SparklineChartPoint = { - date: 'Aug 15', + date: '2026-08-15T00:00:00.000Z', + dateLabel: 'Aug 15', value: 2.1, plotValue: 2.1, }; @@ -45,7 +46,8 @@ describe('getSparklineTooltipLabel', () => { it('uses the error message when the point has no value', () => { expect( getSparklineTooltipLabel({ - date: 'Aug 15', + date: '2026-08-15T00:00:00.000Z', + dateLabel: 'Aug 15', value: null, plotValue: 0, error: 'No data', diff --git a/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/timeSeriesChartData.test.ts b/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/timeSeriesChartData.test.ts index 89bb92e379b..478a84885aa 100644 --- a/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/timeSeriesChartData.test.ts +++ b/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/timeSeriesChartData.test.ts @@ -40,14 +40,27 @@ describe('toSparklineChartData', () => { ); expect(result).toEqual([ - { date: '04-27', value: 2, error: undefined, plotValue: 2 }, { - date: '04-28', + date: '2026-04-27T00:00:00.000Z', + dateLabel: '04-27', + value: 2, + error: undefined, + plotValue: 2, + }, + { + date: '2026-04-28T00:00:00.000Z', + dateLabel: '04-28', value: null, error: 'GitHub API 500', plotValue: 5, }, - { date: '04-29', value: 8, error: undefined, plotValue: 8 }, + { + date: '2026-04-29T00:00:00.000Z', + dateLabel: '04-29', + value: 8, + error: undefined, + plotValue: 8, + }, ]); }); @@ -57,6 +70,8 @@ describe('toSparklineChartData', () => { formatDateLabel, ); + expect(result[0].date).toBe('2026-04-27T00:00:00.000Z'); + expect(result[0].dateLabel).toBe('04-27'); expect(result[0].value).toBeNull(); expect(result[0].plotValue).toBe(0); }); @@ -198,7 +213,9 @@ describe('toAggregationSparklinePoints', () => { describe('getSparklineYDomain', () => { it('should pad a single-value series', () => { expect( - getSparklineYDomain([{ date: 'Apr 27', value: 5, plotValue: 5 }]), + getSparklineYDomain([ + { date: '2026-04-27', dateLabel: 'Apr 27', value: 5, plotValue: 5 }, + ]), ).toEqual([4.5, 5.5]); }); diff --git a/workspaces/scorecard/plugins/scorecard/src/utils/timeSeriesChartData.ts b/workspaces/scorecard/plugins/scorecard/src/utils/timeSeriesChartData.ts index 21c8ef0a499..273c0e4a574 100644 --- a/workspaces/scorecard/plugins/scorecard/src/utils/timeSeriesChartData.ts +++ b/workspaces/scorecard/plugins/scorecard/src/utils/timeSeriesChartData.ts @@ -21,7 +21,10 @@ import type { } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; export type SparklineChartPoint = { + /** Unique key for x-axis positioning (original ISO timestamp). */ date: string; + /** Human-readable label shown on the x-axis and in the tooltip. */ + dateLabel: string; value: number | null; error?: string; plotValue: number; @@ -136,7 +139,8 @@ export const toSparklineChartData = ( formatDateLabel: (timestamp: string) => string, ): SparklineChartPoint[] => { const raw = points.map(point => ({ - date: formatDateLabel(point.timestamp), + date: point.timestamp, + dateLabel: formatDateLabel(point.timestamp), value: toNumericValue(point.value), error: point.error, })); From 46c6489fdff651cc394ba6a2a292c2a0a06e2d93 Mon Sep 17 00:00:00 2001 From: Eswaraiahsapram Date: Fri, 4 Sep 2026 14:39:56 +0530 Subject: [PATCH 3/3] fix type declarations --- .../plugins/scorecard/dev/legacy.tsx | 110 +++++++++++++++++- .../scorecard/plugins/scorecard/dev/mocks.ts | 109 ++++++++++++++++- 2 files changed, 214 insertions(+), 5 deletions(-) diff --git a/workspaces/scorecard/plugins/scorecard/dev/legacy.tsx b/workspaces/scorecard/plugins/scorecard/dev/legacy.tsx index 58f9d8127f6..1d229f6557b 100644 --- a/workspaces/scorecard/plugins/scorecard/dev/legacy.tsx +++ b/workspaces/scorecard/plugins/scorecard/dev/legacy.tsx @@ -40,6 +40,8 @@ import type { Metric, EntityMetricDetailResponse, AggregationMetadata, + MetricTimeSeriesResponse, + AggregatedMetricTimeSeriesResponse, } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; import { CatalogEntityPage } from '@backstage/plugin-catalog'; @@ -54,7 +56,12 @@ import { } from '../src/plugin'; import { scorecardTranslations } from '../src/translations'; import { scorecardApiRef } from '../src/api'; -import type { ScorecardApi } from '../src/api/types'; +import type { + GetAggregationTimeSeriesOptions, + GetMetricTimeSeriesOptions, + ScorecardApi, + ScorecardOptions, +} from '../src/api/types'; import type { GetAggregatedScorecardEntitiesOptions } from '../src/components/types'; import { mockAggregatedScorecardData, @@ -63,7 +70,6 @@ import { } from '../__fixtures__/scorecardData'; import { mockAggregatedScorecardEntitiesData } from '../__fixtures__/aggregatedScorecardEntitiesData'; import { mockCatalogApi } from './mocks'; -import { ScorecardOptions } from '../src/api/types'; const mockComponentEntity: Entity = { apiVersion: 'backstage.io/v1alpha1', @@ -95,8 +101,24 @@ class MockScorecardApi implements ScorecardApi { } async getAggregationMetadata( - _aggregationId: string, + aggregationId: string, ): Promise { + if ( + aggregationId === 'avgDeploymentFrequency' || + aggregationId.startsWith('dora.') + ) { + return { + title: 'Average Deployment Frequency', + description: + 'This KPI provides average weekly production deploys over a 30-day window per entity.', + type: 'number', + unit: '/week', + history: true, + visualization: 'sparkline', + aggregationType: 'average', + }; + } + return { title: 'GitHub open issues', description: 'GitHub open issues', @@ -105,6 +127,51 @@ class MockScorecardApi implements ScorecardApi { }; } + async getAggregationTimeSeries({ + aggregationId, + }: GetAggregationTimeSeriesOptions): Promise { + return { + id: aggregationId, + metricId: 'dora.deploymentFrequency', + metadata: { + title: 'Average Deployment Frequency', + description: + 'This KPI provides average weekly production deploys over a 30-day window per entity.', + type: 'number', + unit: '/week', + history: true, + visualization: 'sparkline', + aggregationType: 'average', + }, + points: [ + { + value: 10, + successCount: 5, + errorCount: 0, + total: 5, + status: 'success', + timestamp: '2026-08-23T00:00:00.000Z', + }, + { + value: 6.8, + successCount: 4, + errorCount: 3, + total: 7, + status: 'success', + timestamp: '2026-08-24T00:00:00.000Z', + }, + ], + thresholds: { + rules: [ + { key: 'elite', expression: '>=7', color: 'success.main' }, + { key: 'medium', expression: '1-7', color: 'warning.main' }, + { key: 'error', expression: '<1', color: 'error.main' }, + ], + }, + aggregationChartDisplayColor: 'warning.main', + }; + } + async getMetrics(_options: { metricIds: string[]; }): Promise<{ metrics: Metric[] }> { @@ -131,6 +198,43 @@ class MockScorecardApi implements ScorecardApi { options.pageSize ?? 10, ) as EntityMetricDetailResponse; } + + async getMetricTimeSeries({ + entity, + metricId, + }: GetMetricTimeSeriesOptions): Promise { + return { + metricId, + entityRef: `${entity.kind}:${entity.metadata.namespace}/${entity.metadata.name}`, + points: [ + { value: 8, timestamp: '2026-04-27T23:10:00.000Z' }, + { value: 7, timestamp: '2026-04-28T22:55:00.000Z' }, + ], + metadata: { + title: metricId, + description: '', + type: 'number', + history: true, + defaultVisualization: 'sparkline', + }, + }; + } + + async getMetricCollectors(metricId: string) { + if (metricId.startsWith('dora.')) { + return [ + { + id: 'github:deploymentWorkflowRuns', + description: 'Collects deployments from GitHub Actions.', + }, + { + id: 'jira:incidents', + description: 'Collects Jira incidents.', + }, + ]; + } + return []; + } } const ScorecardWrapper = ({ children }: { children: ReactNode }) => ( diff --git a/workspaces/scorecard/plugins/scorecard/dev/mocks.ts b/workspaces/scorecard/plugins/scorecard/dev/mocks.ts index 870ec7f071e..554bc0c0d8c 100644 --- a/workspaces/scorecard/plugins/scorecard/dev/mocks.ts +++ b/workspaces/scorecard/plugins/scorecard/dev/mocks.ts @@ -22,6 +22,8 @@ import { type Metric, type EntityMetricDetailResponse, type AggregationMetadata, + type MetricTimeSeriesResponse, + type AggregatedMetricTimeSeriesResponse, aggregationTypes, } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; @@ -33,7 +35,12 @@ import { mockScorecardSuccessData, } from '../__fixtures__/scorecardData'; import { mockAggregatedScorecardEntitiesData } from '../__fixtures__/aggregatedScorecardEntitiesData'; -import { ScorecardApi, ScorecardOptions } from '../src/api/types'; +import { + ScorecardApi, + ScorecardOptions, + GetMetricTimeSeriesOptions, + GetAggregationTimeSeriesOptions, +} from '../src/api/types'; /** mock catalog entity so the Catalog shows one entity and the Scorecard tab can be opened. */ export const mockComponentEntity: Entity = { @@ -97,8 +104,24 @@ export class MockScorecardApi implements ScorecardApi { } async getAggregationMetadata( - _aggregationId: string, + aggregationId: string, ): Promise { + if ( + aggregationId === 'avgDeploymentFrequency' || + aggregationId.startsWith('dora.') + ) { + return { + title: 'Average Deployment Frequency', + description: + 'This KPI provides average weekly production deploys over a 30-day window per entity.', + type: 'number', + unit: '/week', + history: true, + visualization: 'sparkline', + aggregationType: aggregationTypes.average, + }; + } + return { title: 'GitHub open issues', description: 'GitHub open issues', @@ -107,4 +130,86 @@ export class MockScorecardApi implements ScorecardApi { aggregationType: aggregationTypes.statusGrouped, }; } + + async getAggregationTimeSeries({ + aggregationId, + }: GetAggregationTimeSeriesOptions): Promise { + return { + id: aggregationId, + metricId: 'dora.deploymentFrequency', + metadata: { + title: 'Average Deployment Frequency', + description: + 'This KPI provides average weekly production deploys over a 30-day window per entity.', + type: 'number', + unit: '/week', + history: true, + visualization: 'sparkline', + aggregationType: aggregationTypes.average, + }, + points: [ + { + value: 10, + successCount: 5, + errorCount: 0, + total: 5, + status: 'success', + timestamp: '2026-08-23T00:00:00.000Z', + }, + { + value: 6.8, + successCount: 4, + errorCount: 3, + total: 7, + status: 'success', + timestamp: '2026-08-24T00:00:00.000Z', + }, + ], + thresholds: { + rules: [ + { key: 'elite', expression: '>=7', color: 'success.main' }, + { key: 'medium', expression: '1-7', color: 'warning.main' }, + { key: 'error', expression: '<1', color: 'error.main' }, + ], + }, + aggregationChartDisplayColor: 'warning.main', + }; + } + + async getMetricTimeSeries({ + entity, + metricId, + }: GetMetricTimeSeriesOptions): Promise { + return { + metricId, + entityRef: `${entity.kind}:${entity.metadata.namespace}/${entity.metadata.name}`, + points: [ + { value: 8, timestamp: '2026-04-27T23:10:00.000Z' }, + { value: 7, timestamp: '2026-04-28T22:55:00.000Z' }, + ], + metadata: { + title: metricId, + description: '', + type: 'number', + history: true, + defaultVisualization: 'sparkline', + }, + }; + } + + async getMetricCollectors(metricId: string) { + if (metricId.startsWith('dora.')) { + return [ + { + id: 'github:deploymentWorkflowRuns', + description: 'Collects deployments from GitHub Actions.', + }, + { + id: 'jira:incidents', + description: 'Collects Jira incidents.', + }, + ]; + } + return []; + } }