Skip to content

Commit

Permalink
feat: add analysis modal (#3174)
Browse files Browse the repository at this point in the history
* reset branch

Signed-off-by: Emily <[email protected]>

* update tsconfig and moment imports

Signed-off-by: Emily <[email protected]>

* rename component for clarity

Signed-off-by: Emily <[email protected]>

* fix moment imports

Signed-off-by: Zach Aller <[email protected]>

* bring in ticker from argo-ui

Signed-off-by: Emily <[email protected]>

---------

Signed-off-by: Emily <[email protected]>
Signed-off-by: Zach Aller <[email protected]>
Co-authored-by: Zach Aller <[email protected]>
  • Loading branch information
emily-blixt-ck and zachaller authored Dec 18, 2023
1 parent 3a257e3 commit 0eff316
Show file tree
Hide file tree
Showing 35 changed files with 4,189 additions and 289 deletions.
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

0 comments on commit 0eff316

Please sign in to comment.