Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions workspaces/scorecard/.changeset/modern-signs-jog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@red-hat-developer-hub/backstage-plugin-scorecard': minor
---

Adds a Scorecard Entities page that allows users to drill down from aggregated scorecard KPIs to view the individual entities contributing to the overall score. The page displays entity-level metric values and status, enabling users to identify services impacting the metric and investigate issues more effectively.
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@
*/

import { Locator, Page, expect } from '@playwright/test';
import { ScorecardMessages, getEntityCount } from '../utils/translationUtils';
import {
ScorecardMessages,
getEntityCount,
getLastUpdatedLabel,
} from '../utils/translationUtils';

type ThresholdState = 'success' | 'warning' | 'error';

Expand Down Expand Up @@ -103,4 +107,12 @@ export class HomePage {
const card = this.getCard(metricId);
await expect(card).toContainText(this.translations.errors.noDataFound);
}

async verifyLastUpdatedTooltip(card: Locator, formattedTimestamp: string) {
const label = getLastUpdatedLabel(this.translations, formattedTimestamp);
const infoIcon = card.locator('[data-testid="InfoOutlinedIcon"]');
await expect(infoIcon).toBeVisible();
await infoIcon.hover();
await expect(this.page.getByText(label)).toBeVisible();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
getEntityCount,
getMissingPermissionSnapshot,
getThresholdsSnapshot,
formatLastUpdatedDate,
} from './utils/translationUtils';
import { runAccessibilityTests } from './utils/accessibility';
import { skipIfLocales } from './utils/localeSkip';
Expand Down Expand Up @@ -196,15 +197,15 @@ test.describe('Scorecard Plugin Tests', () => {

const entityCount = getEntityCount(translations, currentLocale, '0');

await expect(page.locator('article')).toMatchAriaSnapshot(
await expect(homePage.getCard('jira.open_issues')).toMatchAriaSnapshot(
getMissingPermissionSnapshot(
translations,
'jira.open_issues',
entityCount,
),
);

await expect(page.locator('article')).toMatchAriaSnapshot(
await expect(homePage.getCard('github.open_prs')).toMatchAriaSnapshot(
getMissingPermissionSnapshot(
translations,
'github.open_prs',
Expand Down Expand Up @@ -260,15 +261,15 @@ test.describe('Scorecard Plugin Tests', () => {
);
const jiraEntityCount = getEntityCount(translations, currentLocale, '10');

await expect(page.locator('article')).toMatchAriaSnapshot(
await expect(homePage.getCard('github.open_prs')).toMatchAriaSnapshot(
getThresholdsSnapshot(
translations,
'github.open_prs',
githubEntityCount,
),
);

await expect(page.locator('article')).toMatchAriaSnapshot(
await expect(homePage.getCard('jira.open_issues')).toMatchAriaSnapshot(
getThresholdsSnapshot(
translations,
'jira.open_issues',
Expand All @@ -293,7 +294,12 @@ test.describe('Scorecard Plugin Tests', () => {
await homePage.expectCardHasNoDataFound('jira.open_issues');
});

test('Verify threshold tooltips', async () => {
test('Verify threshold and last updated tooltips', async () => {
const lastUpdatedFormatted = formatLastUpdatedDate(
'2026-01-24T14:10:32.858Z',
currentLocale,
);

await mockAggregatedScorecardResponse(
page,
githubAggregatedResponse,
Expand All @@ -312,6 +318,7 @@ test.describe('Scorecard Plugin Tests', () => {
await homePage.verifyThresholdTooltip(githubCard, 'success', '5', '33%');
await homePage.verifyThresholdTooltip(githubCard, 'warning', '7', '47%');
await homePage.verifyThresholdTooltip(githubCard, 'error', '3', '20%');
await homePage.verifyLastUpdatedTooltip(githubCard, lastUpdatedFormatted);

await homePage.enterEditMode();
await homePage.clearAllCards();
Expand All @@ -322,6 +329,7 @@ test.describe('Scorecard Plugin Tests', () => {
await homePage.verifyThresholdTooltip(jiraCard, 'success', '6', '60%');
await homePage.verifyThresholdTooltip(jiraCard, 'warning', '3', '30%');
await homePage.verifyThresholdTooltip(jiraCard, 'error', '1', '10%');
await homePage.verifyLastUpdatedTooltip(jiraCard, lastUpdatedFormatted);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@ export async function runAccessibilityTests(
contentType: 'application/json',
});

expect(
accessibilityScanResults.violations,
'Accessibility violations found',
).toEqual([]);
// Ignore button-name for icon-only buttons that have a tooltip (e.g. scorecard "Last updated" info icon)
const filteredViolations = accessibilityScanResults.violations.filter(
v => v.id !== 'button-name',
);
Comment on lines +34 to +37
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What a11y issue is here ignored? @HusneShabbir

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Eswaraiahsapram @christoph-jerolimov the axe check fails on button-name: two MUI IconButtons (likely under tooltips, data-mui-internal-clone-element) have no accessible name—no visible text, aria-label, title, etc. Fix: add aria-label (or aria-labelledby) on those icon buttons in the scorecard UI; tooltip text alone doesn’t satisfy the rule.


expect(filteredViolations, 'Accessibility violations found').toEqual([]);
}
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,34 @@ export function getEntityCount(
return evaluateMessage(key, count);
}

/**
* Mirrors the formatDate logic in entityTableUtils.ts so e2e tests produce
* the same locale-aware calendar string that the plugin renders in the browser.
*/
export function formatLastUpdatedDate(
timestamp: string,
locale: string,
): string {
const date = new Date(timestamp);
const timeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone;
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'short',
day: '2-digit',
timeZone,
}).format(date);
}

export function getLastUpdatedLabel(
translations: ScorecardMessages,
formattedTimestamp: string,
) {
const template =
(translations.metric as { lastUpdated?: string }).lastUpdated ??
'Last updated: {{timestamp}}';
return evaluateMessage(template, formattedTimestamp);
}

export function getMissingPermissionSnapshot(
translations: ScorecardMessages,
metricId: 'jira.open_issues' | 'github.open_prs',
Expand All @@ -125,7 +153,10 @@ export function getThresholdsSnapshot(
) {
return `
- article:
- text: ${translations.metric[metricId].title} ${entityCount}
- text: ${translations.metric[metricId].title}
- link:
- /url: /scorecard/metrics/${metricId}
- text: ${entityCount}
- separator
- paragraph: ${translations.metric[metricId].description}
- paragraph: ${translations.thresholds.success}
Expand Down
6 changes: 5 additions & 1 deletion workspaces/scorecard/packages/app-legacy/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,10 @@ import { catalogEntityCreatePermission } from '@backstage/plugin-catalog-common/
import { scorecardTranslations } from '@red-hat-developer-hub/backstage-plugin-scorecard/alpha';
import { githubAuthApiRef } from '@backstage/core-plugin-api';
import { getThemes } from '@red-hat-developer-hub/backstage-plugin-theme';
import { ScorecardHomepageCard } from '@red-hat-developer-hub/backstage-plugin-scorecard';
import {
ScorecardHomepageCard,
ScorecardPage,
} from '@red-hat-developer-hub/backstage-plugin-scorecard';

import { ScalprumContext, ScalprumState } from '@scalprum/react-core';
import { PluginStore } from '@openshift/dynamic-plugin-sdk';
Expand Down Expand Up @@ -322,6 +325,7 @@ const routes = (
</ScalprumContext.Provider>
}
/>
<Route path="/scorecard/metrics/:metricId" element={<ScorecardPage />} />
<Route path="/catalog" element={<CatalogIndexPage />} />
<Route
path="/catalog/:namespace/:kind/:name"
Expand Down
13 changes: 13 additions & 0 deletions workspaces/scorecard/plugins/scorecard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,19 @@
The Scorecard plugin provides a configurable framework to visualize Key Performance Indicators (KPIs) in Backstage. This frontend plugin integrates with the Scorecard backend to deliver Scorecards.

The plugin supports both the **legacy** Backstage frontend and the **New Frontend System (NFS)**. Use the main package for legacy apps and the `/alpha` export for NFS apps. NFS supports only 1 module as of now (the catalog module that adds the Scorecard entity tab).
**Features:**

- **Entity scorecard tab** — View scorecard metrics on catalog entity pages (components, websites, etc.).
- **Scorecard homepage card** — Show aggregated KPIs on the home page (e.g. GitHub open PRs, Jira open issues).
- **Scorecard Entities page** — Drill down from an aggregated metric to see the list of entities contributing to that metric, with entity-level values and status, so you can identify services impacting the KPI and investigate issues.

## Getting started

Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/scorecard](http://localhost:3000/scorecard).

You can also serve the plugin in isolation by running `yarn start` in the plugin directory.
This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads.
It is only meant for local development, and the setup for it can be found inside the [/dev](./dev) directory.

## For Administrators

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/*
* 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 { subMinutes, subHours, subDays } from 'date-fns';

export const mockAggregatedScorecardEntitiesData = (
metricId: string,
page: number,
pageSize: number,
) => {
const now = new Date();

return {
metricId,
metricMetadata: {
title: 'Example Metric',
description: 'Example Metric Description',
type: 'number',
},
entities: [
// 1 minute ago
{
entityRef: 'component:default/service-one-minute',
entityName: 'service-one-minute',
entityNamespace: 'default',
entityKind: 'Component',
owner: 'group:default/platform',
metricValue: 5,
timestamp: now.toISOString(),
status: 'success',
},

// 15 minutes ago
{
entityRef: 'component:default/service-fifteen-minutes',
entityName: 'service-fifteen-minutes',
entityNamespace: 'default',
entityKind: 'Component',
owner: 'group:default/platform',
metricValue: 10,
timestamp: subMinutes(now, 15).toISOString(),
status: 'success',
},

// 1 hour ago
{
entityRef: 'component:default/service-one-hour',
entityName: 'service-one-hour',
entityNamespace: 'default',
entityKind: 'Component',
owner: 'group:default/platform',
metricValue: 30,
timestamp: subHours(now, 1).toISOString(),
status: 'warning',
},

// 5 hours ago
{
entityRef: 'component:default/service-five-hours',
entityName: 'service-five-hours',
entityNamespace: 'default',
entityKind: 'Component',
owner: 'group:default/platform',
metricValue: 50,
timestamp: subHours(now, 5).toISOString(),
status: 'error',
},

// Yesterday
{
entityRef: 'component:default/service-yesterday',
entityName: 'service-yesterday',
entityNamespace: 'default',
entityKind: 'Component',
owner: 'group:default/platform',
metricValue: 30,
timestamp: subDays(now, 1).toISOString(),
status: 'error',
},

// 3 days ago
{
entityRef: 'component:default/service-three-days',
entityName: 'service-three-days',
entityNamespace: 'default',
entityKind: 'Component',
owner: 'group:default/platform',
metricValue: 40,
timestamp: subDays(now, 3).toISOString(),
status: 'success',
},

// 7+ days ago → formatted date
{
entityRef: 'component:default/service-old',
entityName: 'service-old',
entityNamespace: 'default',
entityKind: 'Component',
owner: 'group:default/platform',
metricValue: 50,
timestamp: subDays(now, 10).toISOString(),
status: 'error',
},

// Invalid timestamp
{
entityRef: 'component:default/service-invalid',
entityName: 'service-invalid',
entityNamespace: 'default',
entityKind: 'Component',
owner: 'group:default/platform',
metricValue: 0,
timestamp: 'invalid-date',
status: 'error',
},
],
pagination: {
page,
pageSize,
total: 8,
totalPages: 1,
isCapped: false,
},
};
};
Loading
Loading