1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-24 01:18:01 +02:00

feat: application issues (#6347)

![image](https://github.com/Unleash/unleash/assets/964450/90153533-322c-46fd-8a1b-5853cbe0c35c)
This commit is contained in:
Jaanus Sellin 2024-02-27 09:57:50 +02:00 committed by GitHub
parent 3704956a06
commit 7cebf7b8fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 325 additions and 159 deletions

View File

@ -0,0 +1,27 @@
import { screen } from '@testing-library/react';
import { render } from 'utils/testRenderer';
import { ApplicationIssues } from './ApplicationIssues';
import { ApplicationOverviewIssuesSchema } from 'openapi';
test('Display all application issues', async () => {
const issues: ApplicationOverviewIssuesSchema[] = [
{
type: 'missingFeatures',
items: ['my-app'],
},
{
type: 'missingStrategies',
items: ['defaultStrategy', 'mainStrategy'],
},
];
render(<ApplicationIssues issues={issues} />);
await screen.findByText('my-app');
await screen.findByText('mainStrategy');
await screen.findByText(
`We detected 1 feature flag defined in the SDK that does not exist in Unleash`,
);
await screen.findByText(
`We detected 2 strategy types defined in the SDK that do not exist in Unleash`,
);
});

View File

@ -0,0 +1,117 @@
import { Box, styled } from '@mui/material';
import { ConditionallyRender } from '../../common/ConditionallyRender/ConditionallyRender';
import { WarningAmberRounded } from '@mui/icons-material';
import { ApplicationOverviewIssuesSchema } from 'openapi';
const WarningContainer = styled(Box)(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
paddingBottom: theme.spacing(8),
}));
const WarningHeader = styled(Box)(({ theme }) => ({
display: 'flex',
padding: theme.spacing(2, 3, 2, 3),
alignItems: 'flex-start',
gap: theme.spacing(1.5),
alignSelf: 'stretch',
borderRadius: `${theme.shape.borderRadiusLarge}px ${theme.shape.borderRadiusLarge}px 0 0`,
border: `1px solid ${theme.palette.warning.border}`,
background: theme.palette.warning.light,
}));
const SmallText = styled(Box)(({ theme }) => ({
fontSize: theme.fontSizes.smallBody,
}));
const WarningHeaderText = styled(SmallText)(({ theme }) => ({
color: theme.palette.warning.dark,
fontWeight: theme.fontWeight.bold,
}));
const StyledList = styled('ul')(({ theme }) => ({
padding: theme.spacing(0, 0, 0, 2),
}));
const StyledListElement = styled('li')(({ theme }) => ({
fontWeight: theme.fontWeight.bold,
fontSize: theme.fontSizes.smallBody,
}));
const IssueContainer = styled(Box)(({ theme }) => ({
display: 'flex',
padding: theme.spacing(3),
flexDirection: 'column',
alignItems: 'flex-start',
alignSelf: 'stretch',
gap: theme.spacing(3),
borderRadius: ` 0 0 ${theme.shape.borderRadiusLarge}px ${theme.shape.borderRadiusLarge}px`,
border: `1px solid ${theme.palette.warning.border}`,
}));
const IssueTextContainer = styled(Box)(({ theme }) => ({
display: 'flex',
padding: theme.spacing(2),
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'flex-start',
alignSelf: 'stretch',
gap: theme.spacing(0.5),
borderRadius: theme.spacing(1),
border: `1px solid ${theme.palette.divider}`,
}));
export interface IApplicationIssuesProps {
issues: ApplicationOverviewIssuesSchema[];
}
const resolveIssueText = (issue: ApplicationOverviewIssuesSchema) => {
const issueCount = issue.items.length;
let issueText = '';
switch (issue.type) {
case 'missingFeatures':
issueText = `feature flag${issueCount !== 1 ? 's' : ''}`;
break;
case 'missingStrategies':
issueText = `strategy type${issueCount !== 1 ? 's' : ''}`;
break;
}
return `We detected ${issueCount} ${issueText} defined in the SDK that ${
issueCount !== 1 ? 'do' : 'does'
} not exist in Unleash`;
};
export const ApplicationIssues = ({ issues }: IApplicationIssuesProps) => {
return (
<ConditionallyRender
condition={issues.length > 0}
show={
<WarningContainer>
<WarningHeader>
<WarningAmberRounded />
<WarningHeaderText>
We detected {issues.length} issues in this
application
</WarningHeaderText>
</WarningHeader>
<IssueContainer>
{issues.map((issue) => (
<IssueTextContainer key={issue.type}>
<SmallText>{resolveIssueText(issue)}</SmallText>
<StyledList>
{issue.items.map((item) => (
<StyledListElement key={item}>
{item}
</StyledListElement>
))}
</StyledList>
</IssueTextContainer>
))}
</IssueContainer>
</WarningContainer>
}
/>
);
};

View File

@ -20,7 +20,7 @@ import { sortTypes } from 'utils/sortTypes';
import { IconCell } from 'component/common/Table/cells/IconCell/IconCell'; import { IconCell } from 'component/common/Table/cells/IconCell/IconCell';
import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell'; import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell';
import { ApplicationUsageCell } from './ApplicationUsageCell/ApplicationUsageCell'; import { ApplicationUsageCell } from './ApplicationUsageCell/ApplicationUsageCell';
import { ApplicationSchema } from '../../../openapi'; import { ApplicationSchema } from 'openapi';
export const ApplicationList = () => { export const ApplicationList = () => {
const { applications: data, loading } = useApplications(); const { applications: data, loading } = useApplications();

View File

@ -2,7 +2,7 @@ import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { styled, Typography, useTheme } from '@mui/material'; import { styled, Typography, useTheme } from '@mui/material';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { ApplicationUsageSchema } from '../../../../openapi'; import { ApplicationUsageSchema } from 'openapi';
export interface IApplicationUsageCellProps { export interface IApplicationUsageCellProps {
usage: ApplicationUsageSchema[] | undefined; usage: ApplicationUsageSchema[] | undefined;

View File

@ -2,7 +2,7 @@ import { screen } from '@testing-library/react';
import { render } from 'utils/testRenderer'; import { render } from 'utils/testRenderer';
import { testServerRoute, testServerSetup } from 'utils/testServer'; import { testServerRoute, testServerSetup } from 'utils/testServer';
import { PaginatedApplicationList } from './PaginatedApplicationList'; import { PaginatedApplicationList } from './PaginatedApplicationList';
import { ApplicationSchema } from '../../../openapi'; import { ApplicationSchema } from 'openapi';
const server = testServerSetup(); const server = testServerSetup();

View File

@ -12,7 +12,7 @@ import { PaginatedTable } from 'component/common/Table';
import { IconCell } from 'component/common/Table/cells/IconCell/IconCell'; import { IconCell } from 'component/common/Table/cells/IconCell/IconCell';
import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell'; import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell';
import { ApplicationUsageCell } from './ApplicationUsageCell/ApplicationUsageCell'; import { ApplicationUsageCell } from './ApplicationUsageCell/ApplicationUsageCell';
import { ApplicationSchema } from '../../../openapi'; import { ApplicationSchema } from 'openapi';
import { import {
encodeQueryParams, encodeQueryParams,
NumberParam, NumberParam,

View File

@ -2,7 +2,7 @@ import { screen } from '@testing-library/react';
import { render } from 'utils/testRenderer'; import { render } from 'utils/testRenderer';
import { testServerRoute, testServerSetup } from 'utils/testServer'; import { testServerRoute, testServerSetup } from 'utils/testServer';
import { Route, Routes } from 'react-router-dom'; import { Route, Routes } from 'react-router-dom';
import { ApplicationOverviewSchema } from '../../openapi'; import { ApplicationOverviewSchema } from 'openapi';
import ApplicationOverview from './ApplicationOverview'; import ApplicationOverview from './ApplicationOverview';
const server = testServerSetup(); const server = testServerSetup();

View File

@ -10,11 +10,11 @@ import {
} from '@mui/material'; } from '@mui/material';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import Check from '@mui/icons-material/CheckCircle';
import Warning from '@mui/icons-material/Warning';
import { ArcherContainer, ArcherElement } from 'react-archer'; import { ArcherContainer, ArcherElement } from 'react-archer';
import { FC, useLayoutEffect, useRef, useState } from 'react'; import { FC, useLayoutEffect, useRef, useState } from 'react';
import { useApplicationOverview } from 'hooks/api/getters/useApplicationOverview/useApplicationOverview'; import { useApplicationOverview } from 'hooks/api/getters/useApplicationOverview/useApplicationOverview';
import { WarningAmberRounded } from '@mui/icons-material';
import { ApplicationIssues } from './ApplicationIssues/ApplicationIssues';
const StyledTable = styled('table')(({ theme }) => ({ const StyledTable = styled('table')(({ theme }) => ({
fontSize: theme.fontSizes.smallerBody, fontSize: theme.fontSizes.smallerBody,
@ -33,41 +33,41 @@ const StyleApplicationContainer = styled(Box)(({ theme }) => ({
justifyContent: 'center', justifyContent: 'center',
})); }));
const StyledApplicationBox = styled(Box)<{ mode: 'success' | 'warning' }>( const StyledApplicationBox = styled(Box)<{
({ theme, mode }) => ({ mode: 'success' | 'warning';
borderRadius: theme.shape.borderRadiusMedium, }>(({ theme, mode }) => ({
border: '1px solid', borderRadius: theme.shape.borderRadiusMedium,
borderColor: theme.palette[mode].border, border: '1px solid',
backgroundColor: theme.palette[mode].light, borderColor: theme.palette[mode].border,
display: 'flex', backgroundColor: theme.palette[mode].light,
flexDirection: 'column', display: 'flex',
alignItems: 'center', flexDirection: 'column',
padding: theme.spacing(1.5, 3, 2, 3), alignItems: 'center',
}), padding: theme.spacing(1.5, 3, 2, 3),
); }));
const StyledStatus = styled(Typography)<{ mode: 'success' | 'warning' }>( const StyledStatus = styled(Typography)<{
({ theme, mode }) => ({ mode: 'success' | 'warning';
gap: theme.spacing(1), }>(({ theme, mode }) => ({
fontSize: theme.fontSizes.smallBody, gap: theme.spacing(1),
color: theme.palette[mode].dark, fontSize: theme.fontSizes.smallBody,
display: 'flex', color: theme.palette[mode].dark,
alignItems: 'center', display: 'flex',
}), alignItems: 'center',
); }));
const StyledEnvironmentBox = styled(Box)<{ mode: 'success' | 'warning' }>( const StyledEnvironmentBox = styled(Box)<{
({ theme, mode }) => ({ mode: 'success' | 'warning';
borderRadius: theme.shape.borderRadiusMedium, }>(({ theme, mode }) => ({
border: '1px solid', borderRadius: theme.shape.borderRadiusMedium,
borderColor: border: '1px solid',
theme.palette[mode === 'success' ? 'secondary' : 'warning'].border, borderColor:
backgroundColor: theme.palette[mode === 'success' ? 'secondary' : 'warning'].border,
theme.palette[mode === 'success' ? 'secondary' : 'warning'].light, backgroundColor:
display: 'inline-block', theme.palette[mode === 'success' ? 'secondary' : 'warning'].light,
padding: theme.spacing(1.5, 1.5, 1.5, 1.5), display: 'inline-block',
}), padding: theme.spacing(1.5, 1.5, 1.5, 1.5),
); }));
const StyledDivider = styled(Divider)(({ theme }) => ({ const StyledDivider = styled(Divider)(({ theme }) => ({
marginTop: theme.spacing(2), marginTop: theme.spacing(2),
@ -88,7 +88,7 @@ const EnvironmentHeader = styled(Typography)(({ theme }) => ({
const SuccessStatus = () => ( const SuccessStatus = () => (
<StyledStatus mode='success'> <StyledStatus mode='success'>
<Check <WarningAmberRounded
sx={(theme) => ({ sx={(theme) => ({
color: theme.palette.success.main, color: theme.palette.success.main,
})} })}
@ -99,7 +99,7 @@ const SuccessStatus = () => (
const WarningStatus: FC = ({ children }) => ( const WarningStatus: FC = ({ children }) => (
<StyledStatus mode='warning'> <StyledStatus mode='warning'>
<Warning <WarningAmberRounded
sx={(theme) => ({ sx={(theme) => ({
color: theme.palette.warning.main, color: theme.palette.warning.main,
})} })}
@ -116,7 +116,10 @@ const useElementWidth = () => {
setWidth(`${elementRef.current?.scrollWidth}px`); setWidth(`${elementRef.current?.scrollWidth}px`);
}, [elementRef, setWidth]); }, [elementRef, setWidth]);
return { elementRef, width }; return {
elementRef,
width,
};
}; };
export const ApplicationOverview = () => { export const ApplicationOverview = () => {
@ -135,127 +138,140 @@ export const ApplicationOverview = () => {
const { elementRef, width } = useElementWidth(); const { elementRef, width } = useElementWidth();
const mode: 'success' | 'warning' = 'success'; const mode: 'success' | 'warning' =
data.issues.length === 0 ? 'success' : 'warning';
return ( return (
<ConditionallyRender <ConditionallyRender
condition={!loading && data.environments.length === 0} condition={!loading && data.environments.length === 0}
show={<Alert severity='warning'>No data available.</Alert>} show={<Alert severity='warning'>No data available.</Alert>}
elseShow={ elseShow={
<Box sx={{ width }}> <>
<ArcherContainer <ApplicationIssues issues={data.issues} />
strokeColor={theme.palette.secondary.border} <Box sx={{ width }}>
endMarker={false} <ArcherContainer
> strokeColor={theme.palette.secondary.border}
<StyleApplicationContainer> endMarker={false}
<ArcherElement >
id='application' <StyleApplicationContainer>
relations={data.environments.map(
(environment) => ({
targetId: environment.name,
targetAnchor: 'top',
sourceAnchor: 'bottom',
style: {
strokeColor:
mode === 'success'
? theme.palette.secondary
.border
: theme.palette.warning
.border,
},
}),
)}
>
<StyledApplicationBox mode={mode}>
<Typography
sx={(theme) => ({
fontSize:
theme.fontSizes.smallerBody,
})}
color='text.secondary'
>
Application
</Typography>
<Typography
sx={(theme) => ({
fontSize: theme.fontSizes.bodySize,
fontWeight: theme.fontWeight.bold,
})}
>
{applicationName}
</Typography>
<StyledDivider />
<ConditionallyRender
condition={mode === 'success'}
show={<SuccessStatus />}
elseShow={
<WarningStatus>
3 issues detected
</WarningStatus>
}
/>
</StyledApplicationBox>
</ArcherElement>
</StyleApplicationContainer>
<StyledEnvironmentsContainer ref={elementRef}>
{data.environments.map((environment) => (
<ArcherElement <ArcherElement
id={environment.name} id='application'
key={environment.name} relations={data.environments.map(
(environment) => ({
targetId: environment.name,
targetAnchor: 'top',
sourceAnchor: 'bottom',
style: {
strokeColor:
mode === 'success'
? theme.palette
.secondary.border
: theme.palette.warning
.border,
},
}),
)}
> >
<StyledEnvironmentBox <StyledApplicationBox mode={mode}>
mode={mode} <Typography
sx={(theme) => ({
fontSize:
theme.fontSizes.smallerBody,
})}
color='text.secondary'
>
Application
</Typography>
<Typography
sx={(theme) => ({
fontSize:
theme.fontSizes.bodySize,
fontWeight:
theme.fontWeight.bold,
})}
>
{applicationName}
</Typography>
<StyledDivider />
<ConditionallyRender
condition={mode === 'success'}
show={<SuccessStatus />}
elseShow={
<WarningStatus>
{data.issues.length} issues
detected
</WarningStatus>
}
/>
</StyledApplicationBox>
</ArcherElement>
</StyleApplicationContainer>
<StyledEnvironmentsContainer ref={elementRef}>
{data.environments.map((environment) => (
<ArcherElement
id={environment.name}
key={environment.name} key={environment.name}
> >
<EnvironmentHeader> <StyledEnvironmentBox
{environment.name} environment mode={mode}
</EnvironmentHeader> key={environment.name}
>
<EnvironmentHeader>
{environment.name} environment
</EnvironmentHeader>
<StyledTable> <StyledTable>
<tbody> <tbody>
<tr> <tr>
<StyledCell> <StyledCell>
Instances: Instances:
</StyledCell> </StyledCell>
<StyledCell> <StyledCell>
{ {
environment.instanceCount environment.instanceCount
} }
</StyledCell> </StyledCell>
</tr> </tr>
<tr> <tr>
<StyledCell> <StyledCell>
SDK: SDK:
</StyledCell> </StyledCell>
<StyledCell> <StyledCell>
{environment.sdks.map( {environment.sdks.map(
(sdk) => ( (sdk) => (
<div key={sdk}> <div
{sdk} key={
</div> sdk
), }
)} >
</StyledCell> {sdk}
</tr> </div>
<tr> ),
<StyledCell> )}
Last seen: </StyledCell>
</StyledCell> </tr>
<StyledCell> <tr>
{environment.lastSeen} <StyledCell>
</StyledCell> Last seen:
</tr> </StyledCell>
</tbody> <StyledCell>
</StyledTable> {
</StyledEnvironmentBox> environment.lastSeen
</ArcherElement> }
))} </StyledCell>
</StyledEnvironmentsContainer> </tr>
</ArcherContainer> </tbody>
</Box> </StyledTable>
</StyledEnvironmentBox>
</ArcherElement>
))}
</StyledEnvironmentsContainer>
</ArcherContainer>
</Box>
</>
} }
/> />
); );

View File

@ -22,7 +22,7 @@ import { MetricsSummaryChart } from './MetricsSummaryChart/MetricsSummaryChart';
import { import {
ExecutiveSummarySchemaMetricsSummaryTrendsItem, ExecutiveSummarySchemaMetricsSummaryTrendsItem,
ExecutiveSummarySchemaProjectFlagTrendsItem, ExecutiveSummarySchemaProjectFlagTrendsItem,
} from '../../openapi'; } from 'openapi';
import { HealthStats } from './HealthStats/HealthStats'; import { HealthStats } from './HealthStats/HealthStats';
import { Badge } from 'component/common/Badge/Badge'; import { Badge } from 'component/common/Badge/Badge';

View File

@ -12,7 +12,7 @@ import { useFeedbackContext } from './useFeedback';
import React, { useState } from 'react'; import React, { useState } from 'react';
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
import useToast from 'hooks/useToast'; import useToast from 'hooks/useToast';
import { ProvideFeedbackSchema } from '../../openapi'; import { ProvideFeedbackSchema } from 'openapi';
import { useUserFeedbackApi } from 'hooks/api/actions/useUserFeedbackApi/useUserFeedbackApi'; import { useUserFeedbackApi } from 'hooks/api/actions/useUserFeedbackApi/useUserFeedbackApi';
import { useUserSubmittedFeedback } from 'hooks/useSubmittedFeedback'; import { useUserSubmittedFeedback } from 'hooks/useSubmittedFeedback';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';

View File

@ -13,7 +13,7 @@ import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightC
import { useSearch } from 'hooks/useSearch'; import { useSearch } from 'hooks/useSearch';
import theme from 'themes/theme'; import theme from 'themes/theme';
import { useState } from 'react'; import { useState } from 'react';
import { FeedbackSchema } from '../../openapi'; import { FeedbackSchema } from 'openapi';
interface IFeedbackSchemaCellProps { interface IFeedbackSchemaCellProps {
value?: string | null; // FIXME: proper type value?: string | null; // FIXME: proper type

View File

@ -2,7 +2,7 @@ import { screen } from '@testing-library/react';
import { render } from 'utils/testRenderer'; import { render } from 'utils/testRenderer';
import { testServerRoute, testServerSetup } from 'utils/testServer'; import { testServerRoute, testServerSetup } from 'utils/testServer';
import { ProjectApplications } from './ProjectApplications'; import { ProjectApplications } from './ProjectApplications';
import { ProjectApplicationSchema } from '../../../openapi'; import { ProjectApplicationSchema } from 'openapi';
import { Route, Routes } from 'react-router-dom'; import { Route, Routes } from 'react-router-dom';
import { SEARCH_INPUT } from 'utils/testIds'; import { SEARCH_INPUT } from 'utils/testIds';

View File

@ -19,7 +19,7 @@ import useLoading from 'hooks/useLoading';
import { createColumnHelper, useReactTable } from '@tanstack/react-table'; import { createColumnHelper, useReactTable } from '@tanstack/react-table';
import { withTableState } from 'utils/withTableState'; import { withTableState } from 'utils/withTableState';
import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell'; import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell';
import { ProjectApplicationSchema } from '../../../openapi'; import { ProjectApplicationSchema } from 'openapi';
import mapValues from 'lodash.mapvalues'; import mapValues from 'lodash.mapvalues';
import { import {
DEFAULT_PAGE_LIMIT, DEFAULT_PAGE_LIMIT,

View File

@ -3,6 +3,12 @@ import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler'; import handleErrorResponses from '../httpErrorResponseHandler';
import { ApplicationOverviewSchema } from 'openapi'; import { ApplicationOverviewSchema } from 'openapi';
const placeHolderApplication: ApplicationOverviewSchema = {
environments: [],
featureCount: 0,
projects: [],
issues: [],
};
export const useApplicationOverview = ( export const useApplicationOverview = (
application: string, application: string,
options: SWRConfiguration = {}, options: SWRConfiguration = {},
@ -17,7 +23,7 @@ export const useApplicationOverview = (
); );
return { return {
data: data || { environments: [], featureCount: 0, projects: [] }, data: data || placeHolderApplication,
error, error,
loading: !error && !data, loading: !error && !data,
}; };