Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add analysis modal #3174

Merged
merged 6 commits into from
Dec 18, 2023
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
8 changes: 8 additions & 0 deletions ui/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module.exports = {
roots: ['<rootDir>/src'],
testMatch: ['**/?(*.)+(spec|test).+(ts|tsx|js)'],
transform: {
'^.+\\.(ts|tsx)$': 'ts-jest',
},
modulePathIgnorePatterns: ['generated'],
};
7 changes: 5 additions & 2 deletions ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,15 @@
"react-hot-loader": "^3.1.3",
"react-keyhooks": "^0.2.3",
"react-router-dom": "5.2.0",
"recharts": "^2.9.0",
"rxjs": "^6.6.6",
"typescript": "^5.0.4",
"web-vitals": "^1.0.1"
},
"scripts": {
"start": "NODE_OPTIONS=--openssl-legacy-provider webpack serve --config ./src/app/webpack.dev.js",
"build": "rm -rf dist && NODE_OPTIONS=--openssl-legacy-provider webpack --config ./src/app/webpack.prod.js",
"test": "react-scripts test",
"test": "jest",
"eject": "react-scripts eject",
"protogen": "../hack/swagger-codegen.sh generate -i ../pkg/apiclient/rollout/rollout.swagger.json -l typescript-fetch -o src/models/rollout/generated"
},
Expand All @@ -54,7 +55,7 @@
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
"@types/classnames": "2.2.9",
"@types/jest": "^26.0.15",
"@types/jest": "^29.5.10",
"@types/node": "^12.0.0",
"@types/react": "^16.9.3",
"@types/react-dom": "^16.9.3",
Expand All @@ -64,10 +65,12 @@
"@types/uuid": "^9.0.3",
"@types/react-autocomplete": "^1.8.4",
"copy-webpack-plugin": "^6.3.2",
"jest": "^29.7.0",
"mini-css-extract-plugin": "^1.3.9",
"raw-loader": "^4.0.2",
"react-scripts": "4.0.3",
"sass": "^1.32.8",
"ts-jest": "^29.1.1",
"ts-loader": "^8.0.17",
"webpack-bundle-analyzer": "^4.4.0",
"webpack-cli": "^4.5.0",
Expand Down
81 changes: 81 additions & 0 deletions ui/src/app/components/analysis-modal/analysis-modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import * as React from 'react';
import {Modal, Tabs} from 'antd';
import {RolloutAnalysisRunInfo} from '../../../models/rollout/generated';

import MetricLabel from './metric-label/metric-label';
import {MetricPanel, SummaryPanel} from './panels';
import {analysisEndTime, analysisStartTime, getAdjustedMetricPhase, metricStatusLabel, metricSubstatus, transformMetrics} from './transforms';
import {AnalysisStatus} from './types';

import classNames from 'classnames';
import './styles.scss';

const cx = classNames;

interface AnalysisModalProps {
analysis: RolloutAnalysisRunInfo;
analysisName: string;
images: string[];
onClose: () => void;
open: boolean;
revision: string;
}

export const AnalysisModal = ({analysis, analysisName, images, onClose, open, revision}: AnalysisModalProps) => {
const analysisResults = analysis.specAndStatus?.status;

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const analysisStart = analysisStartTime(analysis.objectMeta?.creationTimestamp);
const analysisEnd = analysisEndTime(analysisResults?.metricResults ?? []);

const analysisSubstatus = metricSubstatus(
(analysisResults?.phase ?? AnalysisStatus.Unknown) as AnalysisStatus,
analysisResults?.runSummary.failed ?? 0,
analysisResults?.runSummary.error ?? 0,
analysisResults?.runSummary.inconclusive ?? 0
);
const transformedMetrics = transformMetrics(analysis.specAndStatus);

const adjustedAnalysisStatus = getAdjustedMetricPhase(analysis.status as AnalysisStatus);

const tabItems = [
{
label: <MetricLabel label='Summary' status={adjustedAnalysisStatus} substatus={analysisSubstatus} />,
key: 'analysis-summary',
children: (
<SummaryPanel
title={metricStatusLabel((analysis.status ?? AnalysisStatus.Unknown) as AnalysisStatus, analysis.failed ?? 0, analysis.error ?? 0, analysis.inconclusive ?? 0)}
status={adjustedAnalysisStatus}
substatus={analysisSubstatus}
images={images}
revision={revision}
message={analysisResults.message}
startTime={analysisStart}
endTime={analysisEnd}
/>
),
},
...Object.values(transformedMetrics)
.sort((a, b) => a.name.localeCompare(b.name))
.map((metric) => ({
label: <MetricLabel label={metric.name} status={metric.status.adjustedPhase} substatus={metric.status.substatus} />,
key: metric.name,
children: (
<MetricPanel
metricName={metric.name}
status={(metric.status.phase ?? AnalysisStatus.Unknown) as AnalysisStatus}
substatus={metric.status.substatus}
metricSpec={metric.spec}
metricResults={metric.status}
/>
),
})),
];

return (
<Modal centered open={open} title={analysisName} onCancel={onClose} width={866} footer={null}>
<Tabs className={cx('tabs')} items={tabItems} tabPosition='left' size='small' tabBarGutter={12} />
</Modal>
);
};
15 changes: 15 additions & 0 deletions ui/src/app/components/analysis-modal/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import {AnalysisStatus, FunctionalStatus} from './types';

export const METRIC_FAILURE_LIMIT_DEFAULT = 0;
export const METRIC_INCONCLUSIVE_LIMIT_DEFAULT = 0;
export const METRIC_CONSECUTIVE_ERROR_LIMIT_DEFAULT = 4;

export const ANALYSIS_STATUS_THEME_MAP: {[key in AnalysisStatus]: string} = {
Successful: FunctionalStatus.SUCCESS,
Error: FunctionalStatus.WARNING,
Failed: FunctionalStatus.ERROR,
Running: FunctionalStatus.IN_PROGRESS,
Pending: FunctionalStatus.INACTIVE,
Inconclusive: FunctionalStatus.WARNING,
Unknown: FunctionalStatus.INACTIVE, // added by frontend
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
@import '../theme/theme.scss';

.criteria-list {
margin: 0;
padding-left: 0;
list-style-type: none;
}

.icon-pass {
color: $success-foreground;
}

.icon-fail {
color: $error-foreground;
}

.icon-pending {
color: $in-progress-foreground;
}
116 changes: 116 additions & 0 deletions ui/src/app/components/analysis-modal/criteria-list/criteria-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import * as React from 'react';
import {Space, Typography} from 'antd';

import {AnalysisStatus} from '../types';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';

import {faCheck, faRotateRight, faXmark} from '@fortawesome/free-solid-svg-icons';

import classNames from 'classnames';
import './criteria-list.scss';

const {Text} = Typography;

enum CriterionStatus {
Fail = 'FAIL',
Pass = 'PASS',
InProgress = 'IN_PROGRESS',
Pending = 'PENDING',
}

const defaultCriterionStatus = (analysisStatus: AnalysisStatus) => (analysisStatus === AnalysisStatus.Pending ? CriterionStatus.Pending : CriterionStatus.InProgress);

const criterionLabel = (measurementLabel: string, maxAllowed: number) => (maxAllowed === 0 ? `No ${measurementLabel}.` : `Fewer than ${maxAllowed + 1} ${measurementLabel}.`);

interface CriteriaListItemProps {
children: React.ReactNode;
showIcon: boolean;
status: CriterionStatus;
}

const CriteriaListItem = ({children, showIcon, status}: CriteriaListItemProps) => {
let StatusIcon: React.ReactNode | null = null;
switch (status) {
case CriterionStatus.Fail: {
StatusIcon = <FontAwesomeIcon icon={faXmark} className={classNames('icon-fail')} />;
break;
}
case CriterionStatus.Pass: {
StatusIcon = <FontAwesomeIcon icon={faCheck} className={classNames('icon-pass')} />;
break;
}
case CriterionStatus.InProgress: {
StatusIcon = <FontAwesomeIcon icon={faRotateRight} className={classNames('icon-pending')} />;
break;
}
case CriterionStatus.Pending:
default: {
break;
}
}

return (
<li>
<Space size='small'>
{showIcon && <>{StatusIcon}</>}
{children}
</Space>
</li>
);
};

interface CriteriaListProps {
analysisStatus: AnalysisStatus;
className?: string[] | string;
consecutiveErrors: number;
failures: number;
inconclusives: number;
maxConsecutiveErrors: number;
maxFailures: number;
maxInconclusives: number;
showIcons: boolean;
}

const CriteriaList = ({
analysisStatus,
className,
consecutiveErrors,
failures,
inconclusives,
maxConsecutiveErrors,
maxFailures,
maxInconclusives,
showIcons,
}: CriteriaListProps) => {
let failureStatus = defaultCriterionStatus(analysisStatus);
let errorStatus = defaultCriterionStatus(analysisStatus);
let inconclusiveStatus = defaultCriterionStatus(analysisStatus);

if (analysisStatus !== AnalysisStatus.Pending && analysisStatus !== AnalysisStatus.Running) {
failureStatus = failures <= maxFailures ? CriterionStatus.Pass : CriterionStatus.Fail;
errorStatus = consecutiveErrors <= maxConsecutiveErrors ? CriterionStatus.Pass : CriterionStatus.Fail;
inconclusiveStatus = inconclusives <= maxInconclusives ? CriterionStatus.Pass : CriterionStatus.Fail;
}

return (
<ul className={classNames('criteria-list', className)}>
{maxFailures > -1 && (
<CriteriaListItem status={failureStatus} showIcon={showIcons}>
<Text>{criterionLabel('measurement failures', maxFailures)}</Text>
</CriteriaListItem>
)}
{maxConsecutiveErrors > -1 && (
<CriteriaListItem status={errorStatus} showIcon={showIcons}>
<Text>{criterionLabel('consecutive measurement errors', maxConsecutiveErrors)}</Text>
</CriteriaListItem>
)}
{maxInconclusives > -1 && (
<CriteriaListItem status={inconclusiveStatus} showIcon={showIcons}>
<Text>{criterionLabel('inconclusive measurements', maxInconclusives)}</Text>
</CriteriaListItem>
)}
</ul>
);
};

export default CriteriaList;
7 changes: 7 additions & 0 deletions ui/src/app/components/analysis-modal/header/header.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.icon {
font-size: 14px;
}

h4.title {
margin: 0; // antd override
}
38 changes: 38 additions & 0 deletions ui/src/app/components/analysis-modal/header/header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import * as React from 'react';

import {Space, Typography} from 'antd';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faMagnifyingGlassChart} from '@fortawesome/free-solid-svg-icons';

import StatusIndicator from '../status-indicator/status-indicator';
import {AnalysisStatus, FunctionalStatus} from '../types';

import classNames from 'classnames/bind';
import './header.scss';

const {Text, Title} = Typography;
const cx = classNames;

interface HeaderProps {
className?: string[] | string;
status: AnalysisStatus;
substatus?: FunctionalStatus.ERROR | FunctionalStatus.WARNING;
subtitle?: string;
title: string;
}

const Header = ({className, status, substatus, subtitle, title}: HeaderProps) => (
<Space className={cx(className)} size='small' align='start'>
<StatusIndicator size='large' status={status} substatus={substatus}>
<FontAwesomeIcon icon={faMagnifyingGlassChart} className={cx('icon', 'fa')} />
</StatusIndicator>
<div>
<Title level={4} className={cx('title')}>
{title}
</Title>
{subtitle && <Text type='secondary'>{subtitle}</Text>}
</div>
</Space>
);

export default Header;
44 changes: 44 additions & 0 deletions ui/src/app/components/analysis-modal/legend/legend.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import * as React from 'react';

import {Space, Typography} from 'antd';

import {AnalysisStatus} from '../types';
import StatusIndicator from '../status-indicator/status-indicator';

import classNames from 'classnames';

const {Text} = Typography;

interface LegendItemProps {
label: string;
status: AnalysisStatus;
}

const LegendItem = ({label, status}: LegendItemProps) => (
<Space size={4}>
<StatusIndicator size='small' status={status} />
<Text>{label}</Text>
</Space>
);

const pluralize = (count: number, singular: string, plural: string) => (count === 1 ? singular : plural);

interface LegendProps {
className?: string[] | string;
errors: number;
failures: number;
inconclusives: number;
successes: number;
}

const Legend = ({className, errors, failures, inconclusives, successes}: LegendProps) => (
<Space className={classNames(className)} size='small'>
<LegendItem status={AnalysisStatus.Successful} label={`${successes} ${pluralize(successes, 'Success', 'Successes')}`} />
<LegendItem status={AnalysisStatus.Failed} label={`${failures} ${pluralize(failures, 'Failure', 'Failures')}`} />
<LegendItem status={AnalysisStatus.Error} label={`${errors} ${pluralize(errors, 'Error', 'Errors')}`} />
{inconclusives > 0 && <LegendItem status={AnalysisStatus.Inconclusive} label={`${inconclusives} Inconclusive`} />}
</Space>
);

export default Legend;
export {LegendItem};
Loading
Loading