Skip to content

Commit 82baa06

Browse files
fix(scorecard): improve error handling in scorecard (#1470)
* fix(scorecard): improve error handling in scorecard * increase missing permission title size * addressed review comments * addressed review comments * fix to match new threshold on metric error * fix conflicts * fix pointer * fix pointer * fix pointer
1 parent 3d695cb commit 82baa06

15 files changed

Lines changed: 948 additions & 103 deletions

workspaces/scorecard/plugins/scorecard/README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,30 @@ To install the Scorecard plugin, run the following command:
2424
yarn workspace app add @red-hat-developer-hub/backstage-plugin-scorecard
2525
```
2626

27+
**Note**
28+
29+
### Permission Framework Support
30+
31+
The Scorecard plugin has support for the permission framework.
32+
33+
- When [RBAC permission](https://github.com/backstage/community-plugins/tree/main/workspaces/rbac/plugins/rbac-backend#installation) framework is enabled, for non-admin users to access scorecard UI, the role associated with your user should have the following permission policies associated with it. Add the following in your permission policies configuration file named `rbac-policy.csv`:
34+
35+
```CSV
36+
p, role:default/team_a, scorecard.metric.read, read, allow
37+
38+
g, user:default/<your-user-name>, role:default/team_a
39+
```
40+
41+
You can specify the path to this configuration file in your application configuration:
42+
43+
```yaml
44+
permission:
45+
enabled: true
46+
rbac:
47+
policies-csv-file: /some/path/rbac-policy.csv
48+
policyFileReload: true
49+
```
50+
2751
### Configuration
2852
2953
1. Add the Scorecard page to you Entity overview page by modifying `packages/app/src/components/catalog/EntityPage.tsx`:

workspaces/scorecard/plugins/scorecard/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"@backstage/core-components": "^0.17.4",
4040
"@backstage/core-plugin-api": "^1.10.9",
4141
"@backstage/plugin-catalog-react": "^1.19.1",
42+
"@backstage/plugin-permission-react": "^0.4.36",
4243
"@backstage/theme": "^0.6.7",
4344
"@mui/icons-material": "5.18.0",
4445
"@mui/material": "5.18.0",

workspaces/scorecard/plugins/scorecard/src/components/Scorecard/NoScorecardsState.tsx renamed to workspaces/scorecard/plugins/scorecard/src/components/Common/NoScorecardsState.tsx

File renamed without changes.
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import Box from '@mui/material/Box';
18+
import Grid from '@mui/material/Grid';
19+
import Button from '@mui/material/Button';
20+
import Typography from '@mui/material/Typography';
21+
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
22+
23+
import permissionRequiredSvg from '../../images/permission-required.svg';
24+
25+
const PermissionRequiredState = () => {
26+
return (
27+
<Box sx={{ p: 4, height: '100%', maxWidth: '1592px', margin: 'auto' }}>
28+
<Grid
29+
container
30+
spacing={4}
31+
alignItems="center"
32+
justifyContent="center"
33+
height="100%"
34+
>
35+
<Grid item xs={12} md={6} sx={{ textAlign: 'left' }}>
36+
<Typography
37+
sx={theme => ({
38+
fontSize: '2.5rem',
39+
fontWeight: 400,
40+
color: theme.palette.text.primary,
41+
mb: 2,
42+
})}
43+
>
44+
Missing permission
45+
</Typography>
46+
47+
<Typography
48+
sx={theme => ({
49+
fontSize: '1rem',
50+
color: theme.palette.text.secondary,
51+
mb: 3,
52+
lineHeight: 1.5,
53+
})}
54+
>
55+
To view Scorecard plugin, contact your administrator to give the{' '}
56+
<Typography
57+
component="span"
58+
sx={theme => ({
59+
fontWeight: 'bold',
60+
color: theme.palette.text.primary,
61+
})}
62+
>
63+
scorecard.metric.read
64+
</Typography>{' '}
65+
permission.
66+
</Typography>
67+
68+
<Button
69+
variant="outlined"
70+
target="_blank"
71+
href="https://github.com/redhat-developer/rhdh-plugins/blob/main/workspaces/scorecard/plugins/scorecard/README.md#permission-framework-support"
72+
sx={theme => ({
73+
color: theme.palette.primary.main,
74+
})}
75+
>
76+
Read more &nbsp; <OpenInNewIcon />
77+
</Button>
78+
</Grid>
79+
80+
<Grid item xs={12} md={6} sx={{ textAlign: 'right' }}>
81+
<Box
82+
component="img"
83+
src={permissionRequiredSvg}
84+
alt="No scorecards"
85+
sx={{
86+
width: '100%',
87+
maxWidth: '600px',
88+
height: 'auto',
89+
}}
90+
/>
91+
</Grid>
92+
</Grid>
93+
</Box>
94+
);
95+
};
96+
97+
export default PermissionRequiredState;

workspaces/scorecard/plugins/scorecard/src/components/Scorecard/__tests__/NoScorecardsState.test.tsx renamed to workspaces/scorecard/plugins/scorecard/src/components/Common/__tests__/NoScorecardsState.test.tsx

File renamed without changes.
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { render, screen } from '@testing-library/react';
18+
import { ThemeProvider, createTheme } from '@mui/material/styles';
19+
import { BrowserRouter } from 'react-router-dom';
20+
21+
import PermissionRequiredState from '../PermissionRequiredState';
22+
23+
// Mock the SVG import
24+
jest.mock(
25+
'../../../images/permission-required.svg',
26+
() => 'mocked-permission-required.svg',
27+
);
28+
29+
// Mock the EmptyState component from Backstage
30+
jest.mock('@backstage/core-components', () => ({
31+
EmptyState: function MockEmptyState(props: any) {
32+
const React = require('react');
33+
return React.createElement('div', { 'data-testid': 'empty-state' }, [
34+
React.createElement(
35+
'div',
36+
{ 'data-testid': 'empty-state-title', key: 'title' },
37+
props.title,
38+
),
39+
React.createElement(
40+
'div',
41+
{ 'data-testid': 'empty-state-description', key: 'description' },
42+
props.description,
43+
),
44+
React.createElement(
45+
'div',
46+
{ 'data-testid': 'empty-state-missing', key: 'missing' },
47+
props.missing && props.missing.customImage,
48+
),
49+
React.createElement(
50+
'div',
51+
{ 'data-testid': 'empty-state-action', key: 'action' },
52+
props.action,
53+
),
54+
]);
55+
},
56+
}));
57+
58+
// Mock the OpenInNew icon
59+
jest.mock('@mui/icons-material/OpenInNew', () => {
60+
return function MockOpenInNewIcon() {
61+
const React = require('react');
62+
return React.createElement(
63+
'span',
64+
{ 'data-testid': 'OpenInNewIcon' },
65+
'OpenInNew',
66+
);
67+
};
68+
});
69+
70+
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => {
71+
const theme = createTheme();
72+
return (
73+
<BrowserRouter>
74+
<ThemeProvider theme={theme}>{children}</ThemeProvider>
75+
</BrowserRouter>
76+
);
77+
};
78+
79+
const renderWithProviders = (component: React.ReactElement) => {
80+
return render(component, { wrapper: TestWrapper });
81+
};
82+
83+
describe('PermissionRequiredState Component', () => {
84+
it('should render the main title', () => {
85+
renderWithProviders(<PermissionRequiredState />);
86+
87+
expect(screen.getByText('Missing permission')).toBeInTheDocument();
88+
});
89+
90+
it('should render the description text with permission name', () => {
91+
renderWithProviders(<PermissionRequiredState />);
92+
93+
expect(
94+
screen.getByText(
95+
/To view Scorecard plugin, contact your administrator to give the/,
96+
),
97+
).toBeInTheDocument();
98+
expect(screen.getByText('scorecard.metric.read')).toBeInTheDocument();
99+
expect(screen.getByText(/permission\./)).toBeInTheDocument();
100+
});
101+
102+
it('should render the permission name with bold styling', () => {
103+
renderWithProviders(<PermissionRequiredState />);
104+
105+
const permissionText = screen.getByText('scorecard.metric.read');
106+
expect(permissionText).toBeInTheDocument();
107+
expect(permissionText.tagName).toBe('SPAN');
108+
});
109+
110+
it('should render the read more link button with correct text', () => {
111+
renderWithProviders(<PermissionRequiredState />);
112+
113+
const linkButton = screen.getByRole('link', { name: /read more/i });
114+
expect(linkButton).toBeInTheDocument();
115+
});
116+
117+
it('should render the OpenInNew icon in the link button', () => {
118+
renderWithProviders(<PermissionRequiredState />);
119+
120+
const linkButton = screen.getByRole('link', { name: /read more/i });
121+
expect(linkButton).toBeInTheDocument();
122+
123+
const icon = linkButton.querySelector('[data-testid="OpenInNewIcon"]');
124+
expect(icon).toBeInTheDocument();
125+
});
126+
127+
it('should have correct href for the read more button', () => {
128+
renderWithProviders(<PermissionRequiredState />);
129+
130+
const linkButton = screen.getByRole('link', { name: /read more/i });
131+
expect(linkButton).toHaveAttribute(
132+
'href',
133+
'https://github.com/redhat-developer/rhdh-plugins/blob/main/workspaces/scorecard/plugins/scorecard/README.md#permission-framework-support',
134+
);
135+
expect(linkButton).toHaveAttribute('target', '_blank');
136+
});
137+
});

workspaces/scorecard/plugins/scorecard/src/components/Scorecard/EntityScorecardContent.tsx

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,22 @@ import { ResponseErrorPanel } from '@backstage/core-components';
2020
import Box from '@mui/material/Box';
2121
import CircularProgress from '@mui/material/CircularProgress';
2222

23-
import NoScorecardsState from './NoScorecardsState';
23+
import NoScorecardsState from '../Common/NoScorecardsState';
2424
import Scorecard from './Scorecard';
2525
import { useScorecards } from '../../hooks/useScorecards';
2626
import { getStatusConfig } from '../../utils/utils';
27+
import { useScorecardMetricsReadPermission } from '../../hooks/useScorecardMetricsReadPermission';
28+
import PermissionRequiredState from '../Common/PermissionRequiredState';
2729

2830
export const EntityScorecardContent = () => {
2931
const { scorecards, loadingData, error } = useScorecards();
3032

31-
if (loadingData) {
33+
const {
34+
allowed: hasReadScorecardMetricsPermission,
35+
loading: loadingPermission,
36+
} = useScorecardMetricsReadPermission();
37+
38+
if (loadingData || loadingPermission) {
3239
return (
3340
<Box
3441
display="flex"
@@ -41,6 +48,10 @@ export const EntityScorecardContent = () => {
4148
);
4249
}
4350

51+
if (!hasReadScorecardMetricsPermission) {
52+
return <PermissionRequiredState />;
53+
}
54+
4455
if (error) {
4556
return <ResponseErrorPanel error={error} />;
4657
}
@@ -57,13 +68,19 @@ export const EntityScorecardContent = () => {
5768
sx={{ alignItems: 'flex-start' }}
5869
>
5970
{scorecards?.map((metric: MetricResult) => {
60-
if (metric.status === 'error') {
61-
return null;
62-
}
71+
// Check if metric data unavailable
72+
const isMetricDataError =
73+
metric.status === 'error' || metric.result?.value === undefined;
6374

64-
const statusConfig = getStatusConfig(
65-
metric.result.thresholdResult?.evaluation,
66-
);
75+
// Check if threshold has an error
76+
const isThresholdError =
77+
metric.result?.thresholdResult?.status === 'error';
78+
79+
const statusConfig = getStatusConfig({
80+
evaluation: metric.result?.thresholdResult?.evaluation,
81+
thresholdStatus: metric.result?.thresholdResult?.status,
82+
metricStatus: metric.status,
83+
});
6784

6885
return (
6986
<Scorecard
@@ -72,9 +89,13 @@ export const EntityScorecardContent = () => {
7289
description={metric.metadata.description}
7390
loading={false}
7491
statusColor={statusConfig.color}
75-
StatusIcon={statusConfig.icon}
76-
value={metric.result.value}
77-
thresholds={metric.result.thresholdResult}
92+
StatusIcon={statusConfig.icon ?? (() => null)}
93+
value={metric.result?.value}
94+
thresholds={metric.result?.thresholdResult}
95+
isMetricDataError={isMetricDataError}
96+
metricDataError={metric?.error}
97+
isThresholdError={isThresholdError}
98+
thresholdError={metric.result?.thresholdResult?.error}
7899
/>
79100
);
80101
})}

0 commit comments

Comments
 (0)