Merge pull request #1517 from github/koesie10/variant-analysis-stats

Add variant analysis stats component
This commit is contained in:
Koen Vlaswinkel
2022-09-22 10:06:22 +02:00
committed by GitHub
24 changed files with 578 additions and 22 deletions

5
.vscode/launch.json vendored
View File

@@ -35,6 +35,9 @@
"runtimeArgs": [
"--inspect=9229"
],
"env": {
"LANG": "en-US"
},
"args": [
"--exit",
"-u",
@@ -43,6 +46,8 @@
"--diff",
"-r",
"ts-node/register",
"-r",
"test/mocha.setup.js",
"test/pure-tests/**/*.ts"
],
"stopOnEntry": false,

View File

@@ -1192,7 +1192,7 @@
"watch:extension": "tsc --watch",
"watch:webpack": "gulp watchView",
"test": "npm-run-all -p test:*",
"test:unit": "mocha --exit -r ts-node/register test/pure-tests/**/*.ts",
"test:unit": "mocha --exit -r ts-node/register -r test/mocha.setup.js test/pure-tests/**/*.ts",
"test:view": "jest",
"preintegration": "rm -rf ./out/vscode-tests && gulp",
"integration": "node ./out/vscode-tests/run-integration-tests.js no-workspace,minimal-workspace",

View File

@@ -0,0 +1,26 @@
/*
* Contains an assortment of helper constants and functions for working with dates.
*/
const dateWithoutYearFormatter = new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
const dateFormatter = new Intl.DateTimeFormat(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
export function formatDate(value: Date): string {
if (value.getFullYear() === new Date().getFullYear()) {
return dateWithoutYearFormatter.format(value);
}
return dateFormatter.format(value);
}

View File

@@ -0,0 +1,15 @@
/*
* Contains an assortment of helper constants and functions for working with numbers.
*/
const numberFormatter = new Intl.NumberFormat('en-US');
/**
* Formats a number to be human-readable with decimal places and thousands separators.
*
* @param value The number to format.
* @returns The formatted number. For example, "10,000", "1,000,000", or "1,000,000,000".
*/
export function formatDecimal(value: number): string {
return numberFormatter.format(value);
}

View File

@@ -2,7 +2,8 @@
* Contains an assortment of helper constants and functions for working with time, dates, and durations.
*/
export const ONE_MINUTE_IN_MS = 1000 * 60;
export const ONE_SECOND_IN_MS = 1000;
export const ONE_MINUTE_IN_MS = ONE_SECOND_IN_MS * 60;
export const ONE_HOUR_IN_MS = ONE_MINUTE_IN_MS * 60;
export const TWO_HOURS_IN_MS = ONE_HOUR_IN_MS * 2;
export const THREE_HOURS_IN_MS = ONE_HOUR_IN_MS * 3;
@@ -43,20 +44,23 @@ export function humanizeRelativeTime(relativeTimeMillis?: number) {
/**
* Converts a number of milliseconds into a human-readable string with units, indicating an amount of time.
* Negative numbers have no meaning and are considered to be "Less than a minute".
* Negative numbers have no meaning and are considered to be "Less than a second".
*
* @param millis The number of milliseconds to convert.
* @returns A humanized duration. For example, "2 minutes", "2 hours", "2 days", or "2 months".
* @returns A humanized duration. For example, "2 seconds", "2 minutes", "2 hours", "2 days", or "2 months".
*/
export function humanizeUnit(millis?: number): string {
// assume a blank or empty string is a zero
// assume anything less than 0 is a zero
if (!millis || millis < ONE_MINUTE_IN_MS) {
return 'Less than a minute';
if (!millis || millis < ONE_SECOND_IN_MS) {
return 'Less than a second';
}
let unit: string;
let unitDiff: number;
if (millis < ONE_HOUR_IN_MS) {
if (millis < ONE_MINUTE_IN_MS) {
unit = 'second';
unitDiff = Math.floor(millis / ONE_SECOND_IN_MS);
} else if (millis < ONE_HOUR_IN_MS) {
unit = 'minute';
unitDiff = Math.floor(millis / ONE_MINUTE_IN_MS);
} else if (millis < ONE_DAY_IN_MS) {

View File

@@ -47,6 +47,12 @@ export default {
disable: true,
},
},
onViewLogsClick: {
action: 'view-logs-clicked',
table: {
disable: true,
}
},
}
} as ComponentMeta<typeof VariantAnalysisHeader>;
@@ -59,16 +65,25 @@ InProgress.args = {
queryName: 'Query name',
queryFileName: 'example.ql',
variantAnalysisStatus: VariantAnalysisStatus.InProgress,
totalRepositoryCount: 10,
completedRepositoryCount: 2,
resultCount: 99_999,
};
export const Succeeded = Template.bind({});
Succeeded.args = {
...InProgress.args,
variantAnalysisStatus: VariantAnalysisStatus.Succeeded,
totalRepositoryCount: 1000,
completedRepositoryCount: 1000,
duration: 720_000,
completedAt: new Date(1661263446000),
};
export const Failed = Template.bind({});
Failed.args = {
...InProgress.args,
variantAnalysisStatus: VariantAnalysisStatus.Failed,
duration: 10_000,
completedAt: new Date(1661263446000),
};

View File

@@ -0,0 +1,82 @@
import React from 'react';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { VariantAnalysisContainer } from '../../view/variant-analysis/VariantAnalysisContainer';
import { VariantAnalysisStats } from '../../view/variant-analysis/VariantAnalysisStats';
import { VariantAnalysisStatus } from '../../remote-queries/shared/variant-analysis';
export default {
title: 'Variant Analysis/Variant Analysis Stats',
component: VariantAnalysisStats,
decorators: [
(Story) => (
<VariantAnalysisContainer>
<Story />
</VariantAnalysisContainer>
)
],
argTypes: {
onViewLogsClick: {
action: 'view-logs-clicked',
table: {
disable: true,
},
},
}
} as ComponentMeta<typeof VariantAnalysisStats>;
const Template: ComponentStory<typeof VariantAnalysisStats> = (args) => (
<VariantAnalysisStats {...args} />
);
export const Starting = Template.bind({});
Starting.args = {
variantAnalysisStatus: VariantAnalysisStatus.InProgress,
totalRepositoryCount: 10,
};
export const Started = Template.bind({});
Started.args = {
...Starting.args,
resultCount: 99_999,
completedRepositoryCount: 2,
};
export const StartedWithWarnings = Template.bind({});
StartedWithWarnings.args = {
...Starting.args,
queryResult: 'warning',
};
export const Succeeded = Template.bind({});
Succeeded.args = {
...Started.args,
totalRepositoryCount: 1000,
completedRepositoryCount: 1000,
variantAnalysisStatus: VariantAnalysisStatus.Succeeded,
duration: 720_000,
completedAt: new Date(1661263446000),
};
export const SucceededWithWarnings = Template.bind({});
SucceededWithWarnings.args = {
...Succeeded.args,
totalRepositoryCount: 10,
completedRepositoryCount: 2,
queryResult: 'warning',
};
export const Failed = Template.bind({});
Failed.args = {
...Starting.args,
variantAnalysisStatus: VariantAnalysisStatus.Failed,
duration: 10_000,
completedAt: new Date(1661263446000),
};
export const Stopped = Template.bind({});
Stopped.args = {
...SucceededWithWarnings.args,
queryResult: 'stopped',
};

View File

@@ -0,0 +1,19 @@
import * as React from 'react';
import styled from 'styled-components';
import classNames from 'classnames';
type Props = {
name: string;
label: string;
className?: string;
};
const CodiconIcon = styled.span`
vertical-align: text-bottom;
`;
export const Codicon = ({
name,
label,
className
}: Props) => <CodiconIcon role="img" aria-label={label} className={classNames('codicon', `codicon-${name}`, className)} />;

View File

@@ -0,0 +1,17 @@
import * as React from 'react';
import styled from 'styled-components';
import { Codicon } from './Codicon';
const Icon = styled(Codicon)`
color: var(--vscode-problemsErrorIcon-foreground);
`;
type Props = {
label?: string;
className?: string;
}
export const ErrorIcon = ({
label = 'Error',
className,
}: Props) => <Icon name="error" label={label} className={className} />;

View File

@@ -0,0 +1,17 @@
import * as React from 'react';
import styled from 'styled-components';
import { Codicon } from './Codicon';
const Icon = styled(Codicon)`
color: var(--vscode-testing-iconPassed);
`;
type Props = {
label?: string;
className?: string;
}
export const SuccessIcon = ({
label = 'Success',
className,
}: Props) => <Icon name="pass" label={label} className={className} />;

View File

@@ -0,0 +1,17 @@
import * as React from 'react';
import styled from 'styled-components';
import { Codicon } from './Codicon';
const Icon = styled(Codicon)`
color: var(--vscode-problemsWarningIcon-foreground);
`;
type Props = {
label?: string;
className?: string;
}
export const WarningIcon = ({
label = 'Warning',
className,
}: Props) => <Icon name="warning" label={label} className={className} />;

View File

@@ -0,0 +1,4 @@
export * from './Codicon';
export * from './ErrorIcon';
export * from './SuccessIcon';
export * from './WarningIcon';

View File

@@ -1,3 +1,4 @@
export * from './icon';
export * from './HorizontalSpace';
export * from './SectionTitle';
export * from './VerticalSpace';

View File

@@ -0,0 +1,27 @@
import * as React from 'react';
import type { ReactNode } from 'react';
import styled from 'styled-components';
type Props = {
title: ReactNode;
children: ReactNode;
};
const Container = styled.div`
flex: 1;
`;
const Header = styled.div`
color: var(--vscode-badge-foreground);
font-size: 0.85em;
font-weight: 800;
text-transform: uppercase;
margin-bottom: 0.6em;
`;
export const StatItem = ({ title, children }: Props) => (
<Container>
<Header>{title}</Header>
<div>{children}</div>
</Container>
);

View File

@@ -9,12 +9,14 @@ export function VariantAnalysis(): JSX.Element {
<VariantAnalysisHeader
queryName="Example query"
queryFileName="example.ql"
totalRepositoryCount={10}
variantAnalysisStatus={VariantAnalysisStatus.InProgress}
onOpenQueryFileClick={() => console.log('Open query')}
onViewQueryTextClick={() => console.log('View query')}
onStopQueryClick={() => console.log('Stop query')}
onCopyRepositoryListClick={() => console.log('Copy repository list')}
onExportResultsClick={() => console.log('Export results')}
onViewLogsClick={() => console.log('View logs')}
/>
</VariantAnalysisContainer>
);

View File

@@ -3,12 +3,22 @@ import styled from 'styled-components';
import { VariantAnalysisStatus } from '../../remote-queries/shared/variant-analysis';
import { QueryDetails } from './QueryDetails';
import { VariantAnalysisActions } from './VariantAnalysisActions';
import { VariantAnalysisStats } from './VariantAnalysisStats';
export type VariantAnalysisHeaderProps = {
queryName: string;
queryFileName: string;
variantAnalysisStatus: VariantAnalysisStatus;
totalRepositoryCount: number;
completedRepositoryCount?: number | undefined;
queryResult?: 'warning' | 'stopped';
resultCount?: number | undefined;
duration?: number | undefined;
completedAt?: Date | undefined;
onOpenQueryFileClick: () => void;
onViewQueryTextClick: () => void;
@@ -16,9 +26,17 @@ export type VariantAnalysisHeaderProps = {
onCopyRepositoryListClick: () => void;
onExportResultsClick: () => void;
onViewLogsClick: () => void;
};
const Container = styled.div`
display: flex;
flex-direction: column;
gap: 2em;
`;
const Row = styled.div`
display: flex;
align-items: center;
`;
@@ -26,26 +44,45 @@ const Container = styled.div`
export const VariantAnalysisHeader = ({
queryName,
queryFileName,
totalRepositoryCount,
completedRepositoryCount,
queryResult,
resultCount,
duration,
completedAt,
variantAnalysisStatus,
onOpenQueryFileClick,
onViewQueryTextClick,
onStopQueryClick,
onCopyRepositoryListClick,
onExportResultsClick
onExportResultsClick,
onViewLogsClick,
}: VariantAnalysisHeaderProps) => {
return (
<Container>
<QueryDetails
queryName={queryName}
queryFileName={queryFileName}
onOpenQueryFileClick={onOpenQueryFileClick}
onViewQueryTextClick={onViewQueryTextClick}
/>
<VariantAnalysisActions
<Row>
<QueryDetails
queryName={queryName}
queryFileName={queryFileName}
onOpenQueryFileClick={onOpenQueryFileClick}
onViewQueryTextClick={onViewQueryTextClick}
/>
<VariantAnalysisActions
variantAnalysisStatus={variantAnalysisStatus}
onStopQueryClick={onStopQueryClick}
onCopyRepositoryListClick={onCopyRepositoryListClick}
onExportResultsClick={onExportResultsClick}
/>
</Row>
<VariantAnalysisStats
variantAnalysisStatus={variantAnalysisStatus}
onStopQueryClick={onStopQueryClick}
onCopyRepositoryListClick={onCopyRepositoryListClick}
onExportResultsClick={onExportResultsClick}
totalRepositoryCount={totalRepositoryCount}
completedRepositoryCount={completedRepositoryCount}
queryResult={queryResult}
resultCount={resultCount}
duration={duration}
completedAt={completedAt}
onViewLogsClick={onViewLogsClick}
/>
</Container>
);

View File

@@ -0,0 +1,37 @@
import * as React from 'react';
import { VariantAnalysisStatus } from '../../remote-queries/shared/variant-analysis';
import { formatDecimal } from '../../pure/number';
import { ErrorIcon, HorizontalSpace, SuccessIcon, WarningIcon } from '../common';
type Props = {
variantAnalysisStatus: VariantAnalysisStatus;
totalRepositoryCount: number;
completedRepositoryCount?: number | undefined;
queryResult?: 'warning' | 'stopped';
};
export const VariantAnalysisRepositoriesStats = ({
variantAnalysisStatus,
totalRepositoryCount,
completedRepositoryCount = 0,
queryResult,
}: Props) => {
if (variantAnalysisStatus === VariantAnalysisStatus.Failed) {
return (
<>
0<HorizontalSpace size={2} /><ErrorIcon />
</>
);
}
return (
<>
{formatDecimal(completedRepositoryCount)}/{formatDecimal(totalRepositoryCount)}
{queryResult && <><HorizontalSpace size={2} /><WarningIcon /></>}
{!queryResult && variantAnalysisStatus === VariantAnalysisStatus.Succeeded &&
<><HorizontalSpace size={2} /><SuccessIcon label="Completed" /></>}
</>
);
};

View File

@@ -0,0 +1,86 @@
import * as React from 'react';
import { useMemo } from 'react';
import styled from 'styled-components';
import { VariantAnalysisStatus } from '../../remote-queries/shared/variant-analysis';
import { StatItem } from './StatItem';
import { formatDecimal } from '../../pure/number';
import { humanizeUnit } from '../../pure/time';
import { VariantAnalysisRepositoriesStats } from './VariantAnalysisRepositoriesStats';
import { VariantAnalysisStatusStats } from './VariantAnalysisStatusStats';
export type VariantAnalysisStatsProps = {
variantAnalysisStatus: VariantAnalysisStatus;
totalRepositoryCount: number;
completedRepositoryCount?: number | undefined;
queryResult?: 'warning' | 'stopped';
resultCount?: number | undefined;
duration?: number | undefined;
completedAt?: Date | undefined;
onViewLogsClick: () => void;
};
const Row = styled.div`
display: flex;
width: 100%;
gap: 1em;
`;
export const VariantAnalysisStats = ({
variantAnalysisStatus,
totalRepositoryCount,
completedRepositoryCount = 0,
queryResult,
resultCount,
duration,
completedAt,
onViewLogsClick,
}: VariantAnalysisStatsProps) => {
const completionHeaderName = useMemo(() => {
if (variantAnalysisStatus === VariantAnalysisStatus.InProgress) {
return 'Running';
}
if (variantAnalysisStatus === VariantAnalysisStatus.Failed) {
return 'Failed';
}
if (queryResult === 'warning') {
return 'Succeeded warnings';
}
if (queryResult === 'stopped') {
return 'Stopped';
}
return 'Succeeded';
}, [variantAnalysisStatus, queryResult]);
return (
<Row>
<StatItem title="Results">
{resultCount !== undefined ? formatDecimal(resultCount) : '-'}
</StatItem>
<StatItem title="Repositories">
<VariantAnalysisRepositoriesStats
variantAnalysisStatus={variantAnalysisStatus}
totalRepositoryCount={totalRepositoryCount}
completedRepositoryCount={completedRepositoryCount}
queryResult={queryResult}
/>
</StatItem>
<StatItem title="Duration">
{duration !== undefined ? humanizeUnit(duration) : '-'}
</StatItem>
<StatItem title={completionHeaderName}>
<VariantAnalysisStatusStats
completedAt={completedAt}
onViewLogsClick={onViewLogsClick}
/>
</StatItem>
</Row>
);
};

View File

@@ -0,0 +1,37 @@
import * as React from 'react';
import styled from 'styled-components';
import { VSCodeLink } from '@vscode/webview-ui-toolkit/react';
import { formatDate } from '../../pure/date';
type Props = {
completedAt?: Date | undefined;
onViewLogsClick: () => void;
};
const Container = styled.div`
display: flex;
flex-direction: column;
gap: 0.5em;
`;
const Icon = styled.span`
font-size: 1em !important;
vertical-align: text-bottom;
`;
export const VariantAnalysisStatusStats = ({
completedAt,
onViewLogsClick,
}: Props) => {
if (completedAt === undefined) {
return <Icon className="codicon codicon-loading codicon-modifier-spin" />;
}
return (
<Container>
<span>{formatDate(completedAt)}</span>
<VSCodeLink onClick={onViewLogsClick}>View logs</VSCodeLink>
</Container>
);
};

View File

@@ -0,0 +1,80 @@
import * as React from 'react';
import { render as reactRender, screen } from '@testing-library/react';
import { VariantAnalysisStatus } from '../../../remote-queries/shared/variant-analysis';
import { VariantAnalysisStats, VariantAnalysisStatsProps } from '../VariantAnalysisStats';
import { userEvent } from '@storybook/testing-library';
describe(VariantAnalysisStats.name, () => {
const onViewLogsClick = jest.fn();
afterEach(() => {
onViewLogsClick.mockReset();
});
const render = (props: Partial<VariantAnalysisStatsProps> = {}) =>
reactRender(
<VariantAnalysisStats
variantAnalysisStatus={VariantAnalysisStatus.InProgress}
totalRepositoryCount={10}
onViewLogsClick={onViewLogsClick}
{...props}
/>
);
it('renders correctly', () => {
render();
expect(screen.getByText('Results')).toBeInTheDocument();
});
it('renders the number of results as a formatted number', () => {
render({ resultCount: 123456 });
expect(screen.getByText('123,456')).toBeInTheDocument();
});
it('renders the number of repositories as a formatted number', () => {
render({ totalRepositoryCount: 123456, completedRepositoryCount: 654321 });
expect(screen.getByText('654,321/123,456')).toBeInTheDocument();
});
it('renders a warning icon when the query result is a warning', () => {
render({ queryResult: 'warning' });
expect(screen.getByRole('img', {
name: 'Warning',
})).toBeInTheDocument();
});
it('renders a warning icon when the query result is stopped', () => {
render({ queryResult: 'stopped' });
expect(screen.getByRole('img', {
name: 'Warning',
})).toBeInTheDocument();
});
it('renders an error icon when the variant analysis status is failed', () => {
render({ variantAnalysisStatus: VariantAnalysisStatus.Failed });
expect(screen.getByRole('img', {
name: 'Error',
})).toBeInTheDocument();
});
it('renders a completed icon when the variant analysis status is succeeded', () => {
render({ variantAnalysisStatus: VariantAnalysisStatus.Succeeded });
expect(screen.getByRole('img', {
name: 'Completed',
})).toBeInTheDocument();
});
it('renders a view logs link when the variant analysis status is succeeded', () => {
render({ variantAnalysisStatus: VariantAnalysisStatus.Succeeded, completedAt: new Date() });
userEvent.click(screen.getByText('View logs'));
expect(onViewLogsClick).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,2 @@
process.env.TZ = 'UTC';
process.env.LANG = 'en-US';

View File

@@ -0,0 +1,11 @@
import { expect } from 'chai';
import 'mocha';
import { formatDate } from '../../src/pure/date';
describe('Date', () => {
it('should return a formatted date', () => {
expect(formatDate(new Date(1663326904000))).to.eq('Sep 16, 11:15 AM');
expect(formatDate(new Date(1631783704000))).to.eq('Sep 16, 2021, 9:15 AM');
});
});

View File

@@ -0,0 +1,12 @@
import { expect } from 'chai';
import 'mocha';
import { formatDecimal } from '../../src/pure/number';
describe('Number', () => {
it('should return a formatted decimal', () => {
expect(formatDecimal(9)).to.eq('9');
expect(formatDecimal(10_000)).to.eq('10,000');
expect(formatDecimal(100_000_000_000)).to.eq('100,000,000,000');
});
});

View File

@@ -5,10 +5,13 @@ import { humanizeRelativeTime, humanizeUnit } from '../../src/pure/time';
describe('Time', () => {
it('should return a humanized unit', () => {
expect(humanizeUnit(undefined)).to.eq('Less than a minute');
expect(humanizeUnit(0)).to.eq('Less than a minute');
expect(humanizeUnit(-1)).to.eq('Less than a minute');
expect(humanizeUnit(1000 * 60 - 1)).to.eq('Less than a minute');
expect(humanizeUnit(undefined)).to.eq('Less than a second');
expect(humanizeUnit(0)).to.eq('Less than a second');
expect(humanizeUnit(-1)).to.eq('Less than a second');
expect(humanizeUnit(1000 - 1)).to.eq('Less than a second');
expect(humanizeUnit(1000)).to.eq('1 second');
expect(humanizeUnit(1000 * 2)).to.eq('2 seconds');
expect(humanizeUnit(1000 * 60 - 1)).to.eq('59 seconds');
expect(humanizeUnit(1000 * 60)).to.eq('1 minute');
expect(humanizeUnit(1000 * 60 * 2 - 1)).to.eq('1 minute');
expect(humanizeUnit(1000 * 60 * 2)).to.eq('2 minutes');