From 4488d338f4739fa6866287d76925e812b9b083d9 Mon Sep 17 00:00:00 2001 From: Niklas Granander Date: Tue, 6 Jul 2021 16:40:48 +0200 Subject: [PATCH 01/16] Create StatusMatrix and StatusCell components Signed-off-by: Niklas Granander --- .../OverviewComponent/OverviewComponent.tsx | 8 ++- .../StatusMatrixComponent.tsx | 57 +++++++++++++++++++ .../components/StatusMatrixComponent/index.ts | 16 ++++++ 3 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 plugins/xcmetrics/src/components/StatusMatrixComponent/StatusMatrixComponent.tsx create mode 100644 plugins/xcmetrics/src/components/StatusMatrixComponent/index.ts diff --git a/plugins/xcmetrics/src/components/OverviewComponent/OverviewComponent.tsx b/plugins/xcmetrics/src/components/OverviewComponent/OverviewComponent.tsx index 18e65dd318..bf2a0b845c 100644 --- a/plugins/xcmetrics/src/components/OverviewComponent/OverviewComponent.tsx +++ b/plugins/xcmetrics/src/components/OverviewComponent/OverviewComponent.tsx @@ -31,6 +31,7 @@ import { useAsync } from 'react-use'; import { Alert } from '@material-ui/lab'; import { Duration } from 'luxon'; import { Chip } from '@material-ui/core'; +import { StatusMatrixComponent } from '../StatusMatrixComponent'; const formatStatus = (status: BuildStatus, warningCount: number) => { const statusIcons = { @@ -113,7 +114,12 @@ export const OverviewComponent = () => { options={{ paging: false, search: false }} data={builds} columns={columns} - title="Latest Builds" + title={ + <> + Latest Builds + + + } /> ); diff --git a/plugins/xcmetrics/src/components/StatusMatrixComponent/StatusMatrixComponent.tsx b/plugins/xcmetrics/src/components/StatusMatrixComponent/StatusMatrixComponent.tsx new file mode 100644 index 0000000000..65d8c7af7a --- /dev/null +++ b/plugins/xcmetrics/src/components/StatusMatrixComponent/StatusMatrixComponent.tsx @@ -0,0 +1,57 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { makeStyles } from '@material-ui/core'; +import React from 'react'; +import { BackstageTheme } from '@backstage/theme'; +import { BuildStatus } from '../../api'; + +const useStyles = makeStyles(theme => ({ + root: { + marginTop: 8, + }, + cell: (props: { status?: BuildStatus }) => { + const statusBackgrounds: { [key in BuildStatus]: string } = { + succeeded: theme.palette.success.main, + failed: theme.palette.error.main, + stopped: theme.palette.warning.main, + }; + + return { + width: 12, + height: 12, + margin: '0 4px 4px 0', + float: 'left', + backgroundColor: statusBackgrounds[props.status!], + }; + }, +})); + +const StatusCell = ({ status }: { status: BuildStatus }) => { + const classes = useStyles({ status }); + return
; +}; + +export const StatusMatrixComponent = () => { + const classes = useStyles(); + + return ( +
+ {[...Array(240).keys()].map(() => ( + + ))} +
+ ); +}; diff --git a/plugins/xcmetrics/src/components/StatusMatrixComponent/index.ts b/plugins/xcmetrics/src/components/StatusMatrixComponent/index.ts new file mode 100644 index 0000000000..623fa9dfb3 --- /dev/null +++ b/plugins/xcmetrics/src/components/StatusMatrixComponent/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './StatusMatrixComponent'; From 0f23dd5e29455cd8edb4d5e464025bcbef321e44 Mon Sep 17 00:00:00 2001 From: Niklas Granander Date: Thu, 8 Jul 2021 11:04:17 +0200 Subject: [PATCH 02/16] Add formatting utils Signed-off-by: Niklas Granander --- .../OverviewComponent/OverviewComponent.tsx | 18 +++++++++---- plugins/xcmetrics/src/utils/format.ts | 25 +++++++++++++++++++ plugins/xcmetrics/src/utils/index.ts | 16 ++++++++++++ 3 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 plugins/xcmetrics/src/utils/format.ts create mode 100644 plugins/xcmetrics/src/utils/index.ts diff --git a/plugins/xcmetrics/src/components/OverviewComponent/OverviewComponent.tsx b/plugins/xcmetrics/src/components/OverviewComponent/OverviewComponent.tsx index bf2a0b845c..9a7bec510a 100644 --- a/plugins/xcmetrics/src/components/OverviewComponent/OverviewComponent.tsx +++ b/plugins/xcmetrics/src/components/OverviewComponent/OverviewComponent.tsx @@ -29,11 +29,17 @@ import { useApi } from '@backstage/core-plugin-api'; import { BuildItem, BuildStatus, xcmetricsApiRef } from '../../api'; import { useAsync } from 'react-use'; import { Alert } from '@material-ui/lab'; -import { Duration } from 'luxon'; import { Chip } from '@material-ui/core'; import { StatusMatrixComponent } from '../StatusMatrixComponent'; +import { formatDuration, formatStatus } from '../../utils'; -const formatStatus = (status: BuildStatus, warningCount: number) => { +const Status = ({ + status, + warningCount, +}: { + status: BuildStatus; + warningCount: number; +}) => { const statusIcons = { succeeded: , failed: , @@ -42,7 +48,7 @@ const formatStatus = (status: BuildStatus, warningCount: number) => { return ( <> - {statusIcons[status]} {status[0].toUpperCase() + status.slice(1)} + {statusIcons[status]} {formatStatus(status)} {warningCount > 0 && ` with ${warningCount} warning`} {warningCount > 1 && 's'} @@ -63,7 +69,7 @@ const columns: TableColumn[] = [ field: 'duration', type: 'time', searchable: false, - render: data => Duration.fromObject({ seconds: data.duration }).toISOTime(), + render: data => formatDuration(data.duration), }, { title: 'User', @@ -72,7 +78,9 @@ const columns: TableColumn[] = [ { title: 'Status', field: 'buildStatus', - render: data => formatStatus(data.buildStatus, data.warningCount), + render: data => ( + + ), }, { field: 'isCI', diff --git a/plugins/xcmetrics/src/utils/format.ts b/plugins/xcmetrics/src/utils/format.ts new file mode 100644 index 0000000000..d8c08d9367 --- /dev/null +++ b/plugins/xcmetrics/src/utils/format.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Duration } from 'luxon'; +import { BuildStatus } from '../api'; + +export const formatDuration = (seconds: number) => + Duration.fromObject({ seconds: Math.round(seconds) }).toISOTime({ + suppressMilliseconds: true, + }); + +export const formatStatus = (status: BuildStatus) => + status[0].toUpperCase() + status.slice(1); diff --git a/plugins/xcmetrics/src/utils/index.ts b/plugins/xcmetrics/src/utils/index.ts new file mode 100644 index 0000000000..a4f1f2c669 --- /dev/null +++ b/plugins/xcmetrics/src/utils/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './format'; From 91d57c5fd618c941914b72a23752e93831114695 Mon Sep 17 00:00:00 2001 From: Niklas Granander Date: Thu, 8 Jul 2021 11:05:03 +0200 Subject: [PATCH 03/16] Add limit parameter to API client Signed-off-by: Niklas Granander --- plugins/xcmetrics/src/api/XCMetricsClient.ts | 4 ++-- plugins/xcmetrics/src/api/types.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/xcmetrics/src/api/XCMetricsClient.ts b/plugins/xcmetrics/src/api/XCMetricsClient.ts index f89cf00123..7d82059480 100644 --- a/plugins/xcmetrics/src/api/XCMetricsClient.ts +++ b/plugins/xcmetrics/src/api/XCMetricsClient.ts @@ -29,9 +29,9 @@ export class XCMetricsClient implements XCMetricsApi { this.discoveryApi = options.discoveryApi; } - async getBuilds(): Promise { + async getBuilds(limit: number = 10): Promise { const baseUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/xcmetrics`; - const response = await fetch(`${baseUrl}/build`); + const response = await fetch(`${baseUrl}/build?per=${limit}`); if (!response.ok) { throw await ResponseError.fromResponse(response); diff --git a/plugins/xcmetrics/src/api/types.ts b/plugins/xcmetrics/src/api/types.ts index 5df05f0375..683b5fd473 100644 --- a/plugins/xcmetrics/src/api/types.ts +++ b/plugins/xcmetrics/src/api/types.ts @@ -54,7 +54,7 @@ export type BuildsResult = { }; export interface XCMetricsApi { - getBuilds(): Promise; + getBuilds(limit?: number): Promise; } export const xcmetricsApiRef = createApiRef({ From 89fca7badc81a859a3dfea3d5b426bf434504e13 Mon Sep 17 00:00:00 2001 From: Niklas Granander Date: Thu, 8 Jul 2021 11:05:36 +0200 Subject: [PATCH 04/16] Create StatusMatrix component Signed-off-by: Niklas Granander --- .../StatusMatrixComponent.test.tsx | 43 +++++++ .../StatusMatrixComponent.tsx | 118 ++++++++++++++---- 2 files changed, 137 insertions(+), 24 deletions(-) create mode 100644 plugins/xcmetrics/src/components/StatusMatrixComponent/StatusMatrixComponent.test.tsx diff --git a/plugins/xcmetrics/src/components/StatusMatrixComponent/StatusMatrixComponent.test.tsx b/plugins/xcmetrics/src/components/StatusMatrixComponent/StatusMatrixComponent.test.tsx new file mode 100644 index 0000000000..17532489a8 --- /dev/null +++ b/plugins/xcmetrics/src/components/StatusMatrixComponent/StatusMatrixComponent.test.tsx @@ -0,0 +1,43 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { StatusMatrixComponent } from './StatusMatrixComponent'; +import { renderInTestApp } from '@backstage/test-utils'; +import { XCMetricsApi, xcmetricsApiRef } from '../../api'; +import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; + +describe('StatusMatrixComponent', () => { + it('should render', async () => { + const mockApi: jest.Mocked = { + getBuilds: jest.fn().mockResolvedValue([ + { + id: 1, + startTimestamp: '2020-11-02T16:38:40Z', + duration: 123.45, + buildStatus: 'succeeded', + }, + ]), + }; + + const rendered = await renderInTestApp( + + + , + ); + + expect(rendered.getByTestId(1)).toBeInTheDocument(); + }); +}); diff --git a/plugins/xcmetrics/src/components/StatusMatrixComponent/StatusMatrixComponent.tsx b/plugins/xcmetrics/src/components/StatusMatrixComponent/StatusMatrixComponent.tsx index 65d8c7af7a..98423822fe 100644 --- a/plugins/xcmetrics/src/components/StatusMatrixComponent/StatusMatrixComponent.tsx +++ b/plugins/xcmetrics/src/components/StatusMatrixComponent/StatusMatrixComponent.tsx @@ -13,45 +13,115 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { makeStyles } from '@material-ui/core'; import React from 'react'; +import { makeStyles, Tooltip } from '@material-ui/core'; import { BackstageTheme } from '@backstage/theme'; -import { BuildStatus } from '../../api'; +import { BuildItem, xcmetricsApiRef } from '../../api'; +import { useAsync, useMeasure } from 'react-use'; +import { formatDuration, formatStatus } from '../../utils'; +import { useApi } from '@backstage/core-plugin-api'; +import { Alert } from '@material-ui/lab'; + +const CELL_SIZE = 12; +const CELL_MARGIN = 4; +const MAX_ROWS = 4; const useStyles = makeStyles(theme => ({ root: { marginTop: 8, + display: 'flex', + flexWrap: 'wrap', + width: '100%', }, - cell: (props: { status?: BuildStatus }) => { - const statusBackgrounds: { [key in BuildStatus]: string } = { - succeeded: theme.palette.success.main, - failed: theme.palette.error.main, - stopped: theme.palette.warning.main, - }; - - return { - width: 12, - height: 12, - margin: '0 4px 4px 0', - float: 'left', - backgroundColor: statusBackgrounds[props.status!], - }; + cell: { + width: CELL_SIZE, + height: CELL_SIZE, + marginRight: CELL_MARGIN, + marginBottom: CELL_MARGIN, + backgroundColor: theme.palette.grey[600], + '&:hover': { + transform: 'scale(1.2)', + }, + }, + succeeded: { + backgroundColor: + theme.palette.type === 'light' + ? theme.palette.success.light + : theme.palette.success.main, + }, + failed: { + backgroundColor: theme.palette.error[theme.palette.type], + }, + stopped: { + backgroundColor: theme.palette.warning[theme.palette.type], + }, + loading: { + animation: `$loadingOpacity 900ms ${theme.transitions.easing.easeInOut}`, + animationIterationCount: 'infinite', + }, + '@keyframes loadingOpacity': { + '0%': { opacity: 0.3 }, + '100%': { opacity: 0.8 }, }, })); -const StatusCell = ({ status }: { status: BuildStatus }) => { - const classes = useStyles({ status }); - return
; -}; +const TooltipContent = ({ build }: { build: BuildItem }) => ( + + + + + + + + + + + + + + + +
Started{new Date(build.startTimestamp).toLocaleString()}
Duration{formatDuration(build.duration)}
Status{formatStatus(build.buildStatus)}
+); export const StatusMatrixComponent = () => { const classes = useStyles(); + const [measureRef, { width: rootWidth }] = useMeasure(); + const client = useApi(xcmetricsApiRef); + const { value: builds, loading, error } = useAsync( + async (): Promise => client.getBuilds(300), + [], + ); + + if (error) { + return {error.message}; + } + + const cols = Math.trunc(rootWidth / (CELL_SIZE + CELL_MARGIN)) || 1; return ( -
- {[...Array(240).keys()].map(() => ( - - ))} +
+ {loading && + [...new Array(cols * MAX_ROWS)].map((_, index) => { + return
; + })} + + {builds && + builds.slice(0, cols * MAX_ROWS).map((build, index) => { + const trimmedBuildStatus = build.buildStatus.split(' ').pop()!; + return ( + } arrow> +
+ + ); + })}
); }; From 51d97b432cda29c22cc71e62f7695af155ff061f Mon Sep 17 00:00:00 2001 From: Niklas Granander Date: Thu, 8 Jul 2021 14:28:40 +0200 Subject: [PATCH 05/16] Test tooltip of cell Signed-off-by: Niklas Granander --- .../StatusMatrixComponent.test.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/plugins/xcmetrics/src/components/StatusMatrixComponent/StatusMatrixComponent.test.tsx b/plugins/xcmetrics/src/components/StatusMatrixComponent/StatusMatrixComponent.test.tsx index 17532489a8..c5217b4fa9 100644 --- a/plugins/xcmetrics/src/components/StatusMatrixComponent/StatusMatrixComponent.test.tsx +++ b/plugins/xcmetrics/src/components/StatusMatrixComponent/StatusMatrixComponent.test.tsx @@ -14,20 +14,23 @@ * limitations under the License. */ import React from 'react'; -import { StatusMatrixComponent } from './StatusMatrixComponent'; import { renderInTestApp } from '@backstage/test-utils'; -import { XCMetricsApi, xcmetricsApiRef } from '../../api'; import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import userEvent from '@testing-library/user-event'; +import { StatusMatrixComponent } from './StatusMatrixComponent'; +import { XCMetricsApi, xcmetricsApiRef } from '../../api'; +import { formatStatus } from '../../utils'; describe('StatusMatrixComponent', () => { it('should render', async () => { + const mockStatus = 'succeeded'; const mockApi: jest.Mocked = { getBuilds: jest.fn().mockResolvedValue([ { id: 1, startTimestamp: '2020-11-02T16:38:40Z', duration: 123.45, - buildStatus: 'succeeded', + buildStatus: mockStatus, }, ]), }; @@ -38,6 +41,12 @@ describe('StatusMatrixComponent', () => { , ); - expect(rendered.getByTestId(1)).toBeInTheDocument(); + const cell = rendered.getByTestId(1); + expect(cell).toBeInTheDocument(); + + userEvent.hover(cell); + expect( + await rendered.findByText(formatStatus(mockStatus)), + ).toBeInTheDocument(); }); }); From d8f5a51e1ad148d2c8e8b85e4ff8804baf2140f1 Mon Sep 17 00:00:00 2001 From: Niklas Granander Date: Thu, 8 Jul 2021 17:59:02 +0200 Subject: [PATCH 06/16] Add utility function for multiple class names Signed-off-by: Niklas Granander --- .../StatusMatrixComponent.tsx | 8 ++-- plugins/xcmetrics/src/utils/classnames.ts | 22 +++++++++++ plugins/xcmetrics/src/utils/index.ts | 1 + plugins/xcmetrics/src/utils/utils.test.ts | 37 +++++++++++++++++++ 4 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 plugins/xcmetrics/src/utils/classnames.ts create mode 100644 plugins/xcmetrics/src/utils/utils.test.ts diff --git a/plugins/xcmetrics/src/components/StatusMatrixComponent/StatusMatrixComponent.tsx b/plugins/xcmetrics/src/components/StatusMatrixComponent/StatusMatrixComponent.tsx index 98423822fe..f25469d292 100644 --- a/plugins/xcmetrics/src/components/StatusMatrixComponent/StatusMatrixComponent.tsx +++ b/plugins/xcmetrics/src/components/StatusMatrixComponent/StatusMatrixComponent.tsx @@ -18,7 +18,7 @@ import { makeStyles, Tooltip } from '@material-ui/core'; import { BackstageTheme } from '@backstage/theme'; import { BuildItem, xcmetricsApiRef } from '../../api'; import { useAsync, useMeasure } from 'react-use'; -import { formatDuration, formatStatus } from '../../utils'; +import { cn, formatDuration, formatStatus } from '../../utils'; import { useApi } from '@backstage/core-plugin-api'; import { Alert } from '@material-ui/lab'; @@ -101,12 +101,12 @@ export const StatusMatrixComponent = () => { return (
{loading && [...new Array(cols * MAX_ROWS)].map((_, index) => { - return
; + return
; })} {builds && @@ -117,7 +117,7 @@ export const StatusMatrixComponent = () => {
); diff --git a/plugins/xcmetrics/src/utils/classnames.ts b/plugins/xcmetrics/src/utils/classnames.ts new file mode 100644 index 0000000000..48b7d3b438 --- /dev/null +++ b/plugins/xcmetrics/src/utils/classnames.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +type ClassName = string | false | undefined | null; + +export const classNames = (...args: ClassName[]) => + args.filter(c => !!c).join(' '); + +export const cn = classNames; diff --git a/plugins/xcmetrics/src/utils/index.ts b/plugins/xcmetrics/src/utils/index.ts index a4f1f2c669..a934044d17 100644 --- a/plugins/xcmetrics/src/utils/index.ts +++ b/plugins/xcmetrics/src/utils/index.ts @@ -14,3 +14,4 @@ * limitations under the License. */ export * from './format'; +export * from './classnames'; diff --git a/plugins/xcmetrics/src/utils/utils.test.ts b/plugins/xcmetrics/src/utils/utils.test.ts new file mode 100644 index 0000000000..3d15b05be2 --- /dev/null +++ b/plugins/xcmetrics/src/utils/utils.test.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { classNames } from './'; + +describe('utils', () => { + describe('classNames', () => { + it('should concatinate strings', () => { + expect(classNames('class1', 'class2', 'class3')).toEqual( + 'class1 class2 class3', + ); + }); + + it('should not include values null, undefined or empty strings', () => { + expect(classNames('class1', undefined, null, '')).toEqual('class1'); + }); + + it('should handle strings with boolean expressions', () => { + expect(classNames(true && 'class1', false && 'class2', false)).toEqual( + 'class1', + ); + }); + }); +}); From 44feafeaf73dfd9c42f597d2e11cb19e30595ffb Mon Sep 17 00:00:00 2001 From: Niklas Granander Date: Tue, 13 Jul 2021 17:11:21 +0200 Subject: [PATCH 07/16] Add error trend component and fetch data from statistics end point Signed-off-by: Niklas Granander --- plugins/xcmetrics/src/api/XCMetricsClient.ts | 19 +++++-- plugins/xcmetrics/src/api/types.ts | 9 +++- .../ErrorTrendComponent.tsx | 53 +++++++++++++++++++ .../components/ErrorTrendComponent/index.ts | 16 ++++++ .../OverviewComponent/OverviewComponent.tsx | 26 ++++++--- 5 files changed, 112 insertions(+), 11 deletions(-) create mode 100644 plugins/xcmetrics/src/components/ErrorTrendComponent/ErrorTrendComponent.tsx create mode 100644 plugins/xcmetrics/src/components/ErrorTrendComponent/index.ts diff --git a/plugins/xcmetrics/src/api/XCMetricsClient.ts b/plugins/xcmetrics/src/api/XCMetricsClient.ts index f89cf00123..5d38108a0d 100644 --- a/plugins/xcmetrics/src/api/XCMetricsClient.ts +++ b/plugins/xcmetrics/src/api/XCMetricsClient.ts @@ -16,7 +16,7 @@ import { DiscoveryApi } from '@backstage/core-plugin-api'; import { ResponseError } from '@backstage/errors'; -import { BuildItem, BuildsResult, XCMetricsApi } from './types'; +import { BuildCount, BuildItem, BuildsResult, XCMetricsApi } from './types'; interface Options { discoveryApi: DiscoveryApi; @@ -29,9 +29,9 @@ export class XCMetricsClient implements XCMetricsApi { this.discoveryApi = options.discoveryApi; } - async getBuilds(): Promise { + async getBuilds(limit = 10): Promise { const baseUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/xcmetrics`; - const response = await fetch(`${baseUrl}/build`); + const response = await fetch(`${baseUrl}/build?per=${limit}`); if (!response.ok) { throw await ResponseError.fromResponse(response); @@ -39,4 +39,17 @@ export class XCMetricsClient implements XCMetricsApi { return ((await response.json()) as BuildsResult).items; } + + async getBuildCounts(days: number): Promise { + const baseUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/xcmetrics`; + const response = await fetch( + `${baseUrl}/statistics/build/errors?days=${days}`, + ); + + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + + return (await response.json()) as BuildCount[]; + } } diff --git a/plugins/xcmetrics/src/api/types.ts b/plugins/xcmetrics/src/api/types.ts index 5df05f0375..ebdf00923e 100644 --- a/plugins/xcmetrics/src/api/types.ts +++ b/plugins/xcmetrics/src/api/types.ts @@ -44,6 +44,12 @@ export type BuildItem = { wasSuspended: boolean; }; +export type BuildCount = { + day: string; + errors: number; + builds: number; +}; + export type BuildsResult = { items: BuildItem[]; metadata: { @@ -54,7 +60,8 @@ export type BuildsResult = { }; export interface XCMetricsApi { - getBuilds(): Promise; + getBuilds(limit?: number): Promise; + getBuildCounts(days: number): Promise; } export const xcmetricsApiRef = createApiRef({ diff --git a/plugins/xcmetrics/src/components/ErrorTrendComponent/ErrorTrendComponent.tsx b/plugins/xcmetrics/src/components/ErrorTrendComponent/ErrorTrendComponent.tsx new file mode 100644 index 0000000000..b8f358a903 --- /dev/null +++ b/plugins/xcmetrics/src/components/ErrorTrendComponent/ErrorTrendComponent.tsx @@ -0,0 +1,53 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { Progress, TrendLine } from '@backstage/core-components'; +import { useApi } from '@backstage/core-plugin-api'; +import { BuildCount, xcmetricsApiRef } from '../../api'; +import { useAsync } from 'react-use'; +import { Alert } from '@material-ui/lab'; + +const TRENDLINE_TITLE = 'Error Rate'; + +interface ErrorTrendProps { + days: number; +} + +export const ErrorTrendComponent = ({ days }: ErrorTrendProps) => { + const client = useApi(xcmetricsApiRef); + const { value: buildCounts, loading, error } = useAsync( + async (): Promise => client.getBuildCounts(days), + [], + ); + + if (loading) { + return ; + } else if (error) { + return {error.message}; + } else if (!buildCounts) { + return ; + } + + let max = 0; + const averageErrors = buildCounts.map(counts => { + if (counts.builds === 0) return 0; + const dayAverage = counts.errors / counts.builds; + max = Math.max(max, dayAverage); + return dayAverage; + }); + + return ; +}; diff --git a/plugins/xcmetrics/src/components/ErrorTrendComponent/index.ts b/plugins/xcmetrics/src/components/ErrorTrendComponent/index.ts new file mode 100644 index 0000000000..974f40a8ae --- /dev/null +++ b/plugins/xcmetrics/src/components/ErrorTrendComponent/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './ErrorTrendComponent'; diff --git a/plugins/xcmetrics/src/components/OverviewComponent/OverviewComponent.tsx b/plugins/xcmetrics/src/components/OverviewComponent/OverviewComponent.tsx index 18e65dd318..69eff0cead 100644 --- a/plugins/xcmetrics/src/components/OverviewComponent/OverviewComponent.tsx +++ b/plugins/xcmetrics/src/components/OverviewComponent/OverviewComponent.tsx @@ -24,13 +24,15 @@ import { Table, TableColumn, EmptyState, + InfoCard, } from '@backstage/core-components'; import { useApi } from '@backstage/core-plugin-api'; import { BuildItem, BuildStatus, xcmetricsApiRef } from '../../api'; import { useAsync } from 'react-use'; import { Alert } from '@material-ui/lab'; import { Duration } from 'luxon'; -import { Chip } from '@material-ui/core'; +import { Chip, Grid, Typography } from '@material-ui/core'; +import { ErrorTrendComponent } from '../ErrorTrendComponent'; const formatStatus = (status: BuildStatus, warningCount: number) => { const statusIcons = { @@ -109,12 +111,22 @@ export const OverviewComponent = () => { Dashboard for XCMetrics - - options={{ paging: false, search: false }} - data={builds} - columns={columns} - title="Latest Builds" - /> + + + + options={{ paging: false, search: false }} + data={builds} + columns={columns} + title="Latest Builds" + /> + + + + Error Rate + + + + ); }; From f3a66ccaf1a65a794026d5743eb19d9d11800e66 Mon Sep 17 00:00:00 2001 From: Niklas Granander Date: Thu, 15 Jul 2021 14:48:04 +0200 Subject: [PATCH 08/16] Use statistics end point for status matrix This enables fetching less data up front and only fetching more when needed. Signed-off-by: Niklas Granander --- plugins/xcmetrics/src/api/XcmetricsClient.ts | 36 +++++- plugins/xcmetrics/src/api/types.ts | 12 +- .../OverviewComponent/OverviewComponent.tsx | 6 +- .../StatusCellComponent.tsx | 109 ++++++++++++++++++ .../components/StatusCellComponent/index.ts | 16 +++ .../StatusMatrixComponent.test.tsx | 25 ++-- .../StatusMatrixComponent.tsx | 72 +++--------- 7 files changed, 198 insertions(+), 78 deletions(-) create mode 100644 plugins/xcmetrics/src/components/StatusCellComponent/StatusCellComponent.tsx create mode 100644 plugins/xcmetrics/src/components/StatusCellComponent/index.ts diff --git a/plugins/xcmetrics/src/api/XcmetricsClient.ts b/plugins/xcmetrics/src/api/XcmetricsClient.ts index 5eec12d285..2fcdaa3150 100644 --- a/plugins/xcmetrics/src/api/XcmetricsClient.ts +++ b/plugins/xcmetrics/src/api/XcmetricsClient.ts @@ -16,7 +16,12 @@ import { DiscoveryApi } from '@backstage/core-plugin-api'; import { ResponseError } from '@backstage/errors'; -import { BuildItem, BuildsResult, XcmetricsApi } from './types'; +import { + Build, + BuildStatusResult, + PaginationResult, + XcmetricsApi, +} from './types'; interface Options { discoveryApi: DiscoveryApi; @@ -29,7 +34,18 @@ export class XcmetricsClient implements XcmetricsApi { this.discoveryApi = options.discoveryApi; } - async getBuilds(limit: number = 10): Promise { + async getBuild(id: string): Promise { + const baseUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/xcmetrics`; + const response = await fetch(`${baseUrl}/build/${id}`); + + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + + return ((await response.json()) as Record<'build', Build>).build; + } + + async getBuilds(limit: number = 10): Promise { const baseUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/xcmetrics`; const response = await fetch(`${baseUrl}/build?per=${limit}`); @@ -37,6 +53,20 @@ export class XcmetricsClient implements XcmetricsApi { throw await ResponseError.fromResponse(response); } - return ((await response.json()) as BuildsResult).items; + return ((await response.json()) as PaginationResult).items; + } + + async getBuildStatuses(limit: number): Promise { + const baseUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/xcmetrics`; + const response = await fetch( + `${baseUrl}/statistics/build/status?per=${limit}`, + ); + + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + + return ((await response.json()) as PaginationResult) + .items; } } diff --git a/plugins/xcmetrics/src/api/types.ts b/plugins/xcmetrics/src/api/types.ts index add59b1a86..d995c53724 100644 --- a/plugins/xcmetrics/src/api/types.ts +++ b/plugins/xcmetrics/src/api/types.ts @@ -18,7 +18,7 @@ import { createApiRef } from '@backstage/core-plugin-api'; export type BuildStatus = 'succeeded' | 'failed' | 'stopped'; -export type BuildItem = { +export type Build = { userid: string; warningCount: number; duration: number; @@ -44,8 +44,10 @@ export type BuildItem = { wasSuspended: boolean; }; -export type BuildsResult = { - items: BuildItem[]; +export type BuildStatusResult = Pick; + +export type PaginationResult = { + items: T[]; metadata: { per: number; total: number; @@ -54,7 +56,9 @@ export type BuildsResult = { }; export interface XcmetricsApi { - getBuilds(): Promise; + getBuild(id: string): Promise; + getBuilds(): Promise; + getBuildStatuses(limit: number): Promise; } export const xcmetricsApiRef = createApiRef({ diff --git a/plugins/xcmetrics/src/components/OverviewComponent/OverviewComponent.tsx b/plugins/xcmetrics/src/components/OverviewComponent/OverviewComponent.tsx index da2aac144b..613a7a031a 100644 --- a/plugins/xcmetrics/src/components/OverviewComponent/OverviewComponent.tsx +++ b/plugins/xcmetrics/src/components/OverviewComponent/OverviewComponent.tsx @@ -26,7 +26,7 @@ import { EmptyState, } from '@backstage/core-components'; import { useApi } from '@backstage/core-plugin-api'; -import { BuildItem, BuildStatus, xcmetricsApiRef } from '../../api'; +import { Build, BuildStatus, xcmetricsApiRef } from '../../api'; import { useAsync } from 'react-use'; import { Alert } from '@material-ui/lab'; import { Chip } from '@material-ui/core'; @@ -55,7 +55,7 @@ const Status = ({ ); }; -const columns: TableColumn[] = [ +const columns: TableColumn[] = [ { title: 'Project', field: 'projectName', @@ -93,7 +93,7 @@ const columns: TableColumn[] = [ export const OverviewComponent = () => { const client = useApi(xcmetricsApiRef); const { value: builds, loading, error } = useAsync( - async (): Promise => client.getBuilds(), + async () => client.getBuilds(), [], ); diff --git a/plugins/xcmetrics/src/components/StatusCellComponent/StatusCellComponent.tsx b/plugins/xcmetrics/src/components/StatusCellComponent/StatusCellComponent.tsx new file mode 100644 index 0000000000..f8c05612a6 --- /dev/null +++ b/plugins/xcmetrics/src/components/StatusCellComponent/StatusCellComponent.tsx @@ -0,0 +1,109 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { makeStyles, Tooltip } from '@material-ui/core'; +import React from 'react'; +import { BackstageTheme } from '@backstage/theme'; +import { BuildStatusResult, xcmetricsApiRef } from '../../api'; +import { cn, formatDuration, formatStatus } from '../../utils'; +import { useAsync } from 'react-use'; +import { useApi } from '@backstage/core-plugin-api'; +import { Progress } from '@backstage/core-components'; + +interface TooltipContentProps { + buildId: string; +} + +const TooltipContent = ({ buildId }: TooltipContentProps) => { + const client = useApi(xcmetricsApiRef); + const { value: build, loading, error } = useAsync( + async () => client.getBuild(buildId), + [], + ); + + if (error) { + return
{error.message}
; + } else if (loading || !build) { + return ; + } + + return ( + + + + + + + + + + + + + + + +
Started{new Date(build.startTimestamp).toLocaleString()}
Duration{formatDuration(build.duration)}
Status{formatStatus(build.buildStatus)}
+ ); +}; + +interface StatusCellProps { + buildStatus: BuildStatusResult; // TODO: Rename this + size: number; + spacing: number; +} + +const useStyles = makeStyles(theme => ({ + root: { + width: ({ size }) => size, + height: ({ size }) => size, + marginRight: ({ spacing }) => spacing, + marginBottom: ({ spacing }) => spacing, + backgroundColor: theme.palette.grey[600], + '&:hover': { + transform: 'scale(1.2)', + }, + }, + succeeded: { + backgroundColor: + theme.palette.type === 'light' + ? theme.palette.success.light + : theme.palette.success.main, + }, + failed: { + backgroundColor: theme.palette.error[theme.palette.type], + }, + stopped: { + backgroundColor: theme.palette.warning[theme.palette.type], + }, +})); + +export const StatusCellComponent = (props: StatusCellProps) => { + const classes = useStyles(props); + const { buildStatus: buildStatusItem } = props; + + return ( + } + enterNextDelay={500} + arrow + > +
+ + ); +}; diff --git a/plugins/xcmetrics/src/components/StatusCellComponent/index.ts b/plugins/xcmetrics/src/components/StatusCellComponent/index.ts new file mode 100644 index 0000000000..e1d4f81e34 --- /dev/null +++ b/plugins/xcmetrics/src/components/StatusCellComponent/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './StatusCellComponent'; diff --git a/plugins/xcmetrics/src/components/StatusMatrixComponent/StatusMatrixComponent.test.tsx b/plugins/xcmetrics/src/components/StatusMatrixComponent/StatusMatrixComponent.test.tsx index c5217b4fa9..3a84bda0e3 100644 --- a/plugins/xcmetrics/src/components/StatusMatrixComponent/StatusMatrixComponent.test.tsx +++ b/plugins/xcmetrics/src/components/StatusMatrixComponent/StatusMatrixComponent.test.tsx @@ -18,21 +18,24 @@ import { renderInTestApp } from '@backstage/test-utils'; import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import userEvent from '@testing-library/user-event'; import { StatusMatrixComponent } from './StatusMatrixComponent'; -import { XCMetricsApi, xcmetricsApiRef } from '../../api'; +import { XcmetricsApi, xcmetricsApiRef } from '../../api'; import { formatStatus } from '../../utils'; describe('StatusMatrixComponent', () => { it('should render', async () => { + const mockId = 'mockId'; const mockStatus = 'succeeded'; - const mockApi: jest.Mocked = { - getBuilds: jest.fn().mockResolvedValue([ - { - id: 1, - startTimestamp: '2020-11-02T16:38:40Z', - duration: 123.45, - buildStatus: mockStatus, - }, - ]), + const mockApi: jest.Mocked = { + getBuildStatuses: jest + .fn() + .mockResolvedValue([{ id: mockId, buildStatus: mockStatus }]), + getBuild: jest.fn().mockResolvedValue({ + id: mockId, + buildStatus: mockStatus, + duration: 10.0, + startTimestamp: new Date().getTime().toString(), + }), + getBuilds: jest.fn().mockResolvedValue([]), }; const rendered = await renderInTestApp( @@ -41,7 +44,7 @@ describe('StatusMatrixComponent', () => { , ); - const cell = rendered.getByTestId(1); + const cell = rendered.getByTestId(mockId); expect(cell).toBeInTheDocument(); userEvent.hover(cell); diff --git a/plugins/xcmetrics/src/components/StatusMatrixComponent/StatusMatrixComponent.tsx b/plugins/xcmetrics/src/components/StatusMatrixComponent/StatusMatrixComponent.tsx index f25469d292..92c605e8fb 100644 --- a/plugins/xcmetrics/src/components/StatusMatrixComponent/StatusMatrixComponent.tsx +++ b/plugins/xcmetrics/src/components/StatusMatrixComponent/StatusMatrixComponent.tsx @@ -14,13 +14,14 @@ * limitations under the License. */ import React from 'react'; -import { makeStyles, Tooltip } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core'; import { BackstageTheme } from '@backstage/theme'; -import { BuildItem, xcmetricsApiRef } from '../../api'; +import { xcmetricsApiRef } from '../../api'; import { useAsync, useMeasure } from 'react-use'; -import { cn, formatDuration, formatStatus } from '../../utils'; +import { cn } from '../../utils'; import { useApi } from '@backstage/core-plugin-api'; import { Alert } from '@material-ui/lab'; +import { StatusCellComponent } from '../StatusCellComponent'; const CELL_SIZE = 12; const CELL_MARGIN = 4; @@ -33,28 +34,6 @@ const useStyles = makeStyles(theme => ({ flexWrap: 'wrap', width: '100%', }, - cell: { - width: CELL_SIZE, - height: CELL_SIZE, - marginRight: CELL_MARGIN, - marginBottom: CELL_MARGIN, - backgroundColor: theme.palette.grey[600], - '&:hover': { - transform: 'scale(1.2)', - }, - }, - succeeded: { - backgroundColor: - theme.palette.type === 'light' - ? theme.palette.success.light - : theme.palette.success.main, - }, - failed: { - backgroundColor: theme.palette.error[theme.palette.type], - }, - stopped: { - backgroundColor: theme.palette.warning[theme.palette.type], - }, loading: { animation: `$loadingOpacity 900ms ${theme.transitions.easing.easeInOut}`, animationIterationCount: 'infinite', @@ -65,31 +44,12 @@ const useStyles = makeStyles(theme => ({ }, })); -const TooltipContent = ({ build }: { build: BuildItem }) => ( - - - - - - - - - - - - - - - -
Started{new Date(build.startTimestamp).toLocaleString()}
Duration{formatDuration(build.duration)}
Status{formatStatus(build.buildStatus)}
-); - export const StatusMatrixComponent = () => { const classes = useStyles(); const [measureRef, { width: rootWidth }] = useMeasure(); const client = useApi(xcmetricsApiRef); const { value: builds, loading, error } = useAsync( - async (): Promise => client.getBuilds(300), + async () => client.getBuildStatuses(300), [], ); @@ -110,18 +70,16 @@ export const StatusMatrixComponent = () => { })} {builds && - builds.slice(0, cols * MAX_ROWS).map((build, index) => { - const trimmedBuildStatus = build.buildStatus.split(' ').pop()!; - return ( - } arrow> -
- - ); - })} + builds + .slice(0, cols * MAX_ROWS) + .map((buildStatus, index) => ( + + ))}
); }; From 7a7680d9504c24d998d786b1deed27995b7dd646 Mon Sep 17 00:00:00 2001 From: Niklas Granander Date: Mon, 19 Jul 2021 11:30:28 +0200 Subject: [PATCH 09/16] Extract cell of status matrix to a component Signed-off-by: Niklas Granander --- .../OverviewComponent.test.tsx | 35 ++++--------- .../StatusCellComponent.test.tsx | 42 +++++++++++++++ .../StatusCellComponent.tsx | 52 ++++++++++++------- .../StatusMatrixComponent.test.tsx | 29 ++--------- .../StatusMatrixComponent.tsx | 8 ++- plugins/xcmetrics/src/test-utils/index.ts | 16 ++++++ .../src/test-utils/mockXcmetricsApi.ts | 44 ++++++++++++++++ 7 files changed, 156 insertions(+), 70 deletions(-) create mode 100644 plugins/xcmetrics/src/components/StatusCellComponent/StatusCellComponent.test.tsx create mode 100644 plugins/xcmetrics/src/test-utils/index.ts create mode 100644 plugins/xcmetrics/src/test-utils/mockXcmetricsApi.ts diff --git a/plugins/xcmetrics/src/components/OverviewComponent/OverviewComponent.test.tsx b/plugins/xcmetrics/src/components/OverviewComponent/OverviewComponent.test.tsx index d386d279ce..545e9e8c1d 100644 --- a/plugins/xcmetrics/src/components/OverviewComponent/OverviewComponent.test.tsx +++ b/plugins/xcmetrics/src/components/OverviewComponent/OverviewComponent.test.tsx @@ -16,28 +16,14 @@ import React from 'react'; import { OverviewComponent } from './OverviewComponent'; import { renderInTestApp } from '@backstage/test-utils'; -import { XcmetricsApi, xcmetricsApiRef } from '../../api'; +import { xcmetricsApiRef } from '../../api'; import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { mockUserId, mockXcmetricsApi } from '../../test-utils'; describe('OverviewComponent', () => { it('should render', async () => { - const mockUserId = 'mockUser'; - const mockApi: jest.Mocked = { - getBuilds: jest.fn().mockResolvedValue([ - { - userid: mockUserId, - warningCount: 1, - duration: 123.45, - isCi: false, - projectName: 'App', - buildStatus: 'succeeded', - schema: 'AppSchema', - }, - ]), - }; - const rendered = await renderInTestApp( - + , ); @@ -47,12 +33,10 @@ describe('OverviewComponent', () => { }); it('should render an empty state when no builds exist', async () => { - const mockApi: jest.Mocked = { - getBuilds: jest.fn().mockResolvedValue([]), - }; + mockXcmetricsApi.getBuilds = jest.fn().mockResolvedValue([]); const rendered = await renderInTestApp( - + , ); @@ -61,12 +45,13 @@ describe('OverviewComponent', () => { it('should show an error when API not responding', async () => { const errorMessage = 'MockErrorMessage'; - const mockApi: jest.Mocked = { - getBuilds: jest.fn().mockRejectedValue({ message: errorMessage }), - }; + + mockXcmetricsApi.getBuilds = jest + .fn() + .mockRejectedValue({ message: errorMessage }); const rendered = await renderInTestApp( - + , ); diff --git a/plugins/xcmetrics/src/components/StatusCellComponent/StatusCellComponent.test.tsx b/plugins/xcmetrics/src/components/StatusCellComponent/StatusCellComponent.test.tsx new file mode 100644 index 0000000000..470f69fa8d --- /dev/null +++ b/plugins/xcmetrics/src/components/StatusCellComponent/StatusCellComponent.test.tsx @@ -0,0 +1,42 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import userEvent from '@testing-library/user-event'; +import { StatusCellComponent } from './StatusCellComponent'; +import { xcmetricsApiRef } from '../../api'; +import { mockBuildId, mockStatus, mockXcmetricsApi } from '../../test-utils'; +import { formatStatus } from '../../utils'; + +describe('StatusCellComponent', () => { + it('should render', async () => { + const rendered = await renderInTestApp( + + + , + ); + + userEvent.hover(rendered.getByTestId(mockBuildId)); + expect( + await rendered.findByText(formatStatus(mockStatus)), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/xcmetrics/src/components/StatusCellComponent/StatusCellComponent.tsx b/plugins/xcmetrics/src/components/StatusCellComponent/StatusCellComponent.tsx index f8c05612a6..b0537c53b7 100644 --- a/plugins/xcmetrics/src/components/StatusCellComponent/StatusCellComponent.tsx +++ b/plugins/xcmetrics/src/components/StatusCellComponent/StatusCellComponent.tsx @@ -16,7 +16,7 @@ import { makeStyles, Tooltip } from '@material-ui/core'; import React from 'react'; import { BackstageTheme } from '@backstage/theme'; -import { BuildStatusResult, xcmetricsApiRef } from '../../api'; +import { BuildStatus, BuildStatusResult, xcmetricsApiRef } from '../../api'; import { cn, formatDuration, formatStatus } from '../../utils'; import { useAsync } from 'react-use'; import { useApi } from '@backstage/core-plugin-api'; @@ -36,7 +36,7 @@ const TooltipContent = ({ buildId }: TooltipContentProps) => { if (error) { return
{error.message}
; } else if (loading || !build) { - return ; + return ; } return ( @@ -60,11 +60,15 @@ const TooltipContent = ({ buildId }: TooltipContentProps) => { }; interface StatusCellProps { - buildStatus: BuildStatusResult; // TODO: Rename this + buildStatus?: BuildStatusResult; size: number; spacing: number; } +type StatusStyle = { + [key in BuildStatus]: any; +}; + const useStyles = makeStyles(theme => ({ root: { width: ({ size }) => size, @@ -76,33 +80,43 @@ const useStyles = makeStyles(theme => ({ transform: 'scale(1.2)', }, }, - succeeded: { - backgroundColor: - theme.palette.type === 'light' - ? theme.palette.success.light - : theme.palette.success.main, - }, - failed: { - backgroundColor: theme.palette.error[theme.palette.type], - }, - stopped: { - backgroundColor: theme.palette.warning[theme.palette.type], - }, + ...({ + succeeded: { + backgroundColor: + theme.palette.type === 'light' + ? theme.palette.success.light + : theme.palette.success.main, + }, + } as StatusStyle), // Make sure that key matches a status + ...({ + failed: { + backgroundColor: theme.palette.error[theme.palette.type], + }, + } as StatusStyle), + ...({ + stopped: { + backgroundColor: theme.palette.warning[theme.palette.type], + }, + } as StatusStyle), })); export const StatusCellComponent = (props: StatusCellProps) => { const classes = useStyles(props); - const { buildStatus: buildStatusItem } = props; + const { buildStatus } = props; + + if (!buildStatus) { + return
; + } return ( } + title={} enterNextDelay={500} arrow >
); diff --git a/plugins/xcmetrics/src/components/StatusMatrixComponent/StatusMatrixComponent.test.tsx b/plugins/xcmetrics/src/components/StatusMatrixComponent/StatusMatrixComponent.test.tsx index 3a84bda0e3..5c3832da91 100644 --- a/plugins/xcmetrics/src/components/StatusMatrixComponent/StatusMatrixComponent.test.tsx +++ b/plugins/xcmetrics/src/components/StatusMatrixComponent/StatusMatrixComponent.test.tsx @@ -16,40 +16,19 @@ import React from 'react'; import { renderInTestApp } from '@backstage/test-utils'; import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; -import userEvent from '@testing-library/user-event'; import { StatusMatrixComponent } from './StatusMatrixComponent'; -import { XcmetricsApi, xcmetricsApiRef } from '../../api'; -import { formatStatus } from '../../utils'; +import { xcmetricsApiRef } from '../../api'; +import { mockBuildId, mockXcmetricsApi } from '../../test-utils'; describe('StatusMatrixComponent', () => { it('should render', async () => { - const mockId = 'mockId'; - const mockStatus = 'succeeded'; - const mockApi: jest.Mocked = { - getBuildStatuses: jest - .fn() - .mockResolvedValue([{ id: mockId, buildStatus: mockStatus }]), - getBuild: jest.fn().mockResolvedValue({ - id: mockId, - buildStatus: mockStatus, - duration: 10.0, - startTimestamp: new Date().getTime().toString(), - }), - getBuilds: jest.fn().mockResolvedValue([]), - }; - const rendered = await renderInTestApp( - + , ); - const cell = rendered.getByTestId(mockId); + const cell = rendered.getByTestId(mockBuildId); expect(cell).toBeInTheDocument(); - - userEvent.hover(cell); - expect( - await rendered.findByText(formatStatus(mockStatus)), - ).toBeInTheDocument(); }); }); diff --git a/plugins/xcmetrics/src/components/StatusMatrixComponent/StatusMatrixComponent.tsx b/plugins/xcmetrics/src/components/StatusMatrixComponent/StatusMatrixComponent.tsx index 92c605e8fb..3a6c7e1618 100644 --- a/plugins/xcmetrics/src/components/StatusMatrixComponent/StatusMatrixComponent.tsx +++ b/plugins/xcmetrics/src/components/StatusMatrixComponent/StatusMatrixComponent.tsx @@ -66,7 +66,13 @@ export const StatusMatrixComponent = () => { > {loading && [...new Array(cols * MAX_ROWS)].map((_, index) => { - return
; + return ( + + ); })} {builds && diff --git a/plugins/xcmetrics/src/test-utils/index.ts b/plugins/xcmetrics/src/test-utils/index.ts new file mode 100644 index 0000000000..55e29260d1 --- /dev/null +++ b/plugins/xcmetrics/src/test-utils/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './mockXcmetricsApi'; diff --git a/plugins/xcmetrics/src/test-utils/mockXcmetricsApi.ts b/plugins/xcmetrics/src/test-utils/mockXcmetricsApi.ts new file mode 100644 index 0000000000..58419dfd48 --- /dev/null +++ b/plugins/xcmetrics/src/test-utils/mockXcmetricsApi.ts @@ -0,0 +1,44 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { XcmetricsApi } from '../api'; + +export const mockUserId = 'user_id'; +export const mockBuildId = 'build_id'; +export const mockStatus = 'succeeded'; + +export const mockXcmetricsApi: jest.Mocked = { + getBuildStatuses: jest + .fn() + .mockResolvedValue([{ id: mockBuildId, status: mockStatus }]), + getBuild: jest.fn().mockResolvedValue({ + id: mockBuildId, + buildStatus: 'succeeded', + duration: 10.0, + startTimestamp: '1626365026', + }), + getBuilds: jest.fn().mockResolvedValue([ + { + userid: mockUserId, + warningCount: 1, + duration: 123.45, + isCi: false, + projectName: 'App', + buildStatus: mockStatus, + schema: 'AppSchema', + }, + ]), + getBuildCounts: jest.fn(), +}; From d54717b139814e072a86388373653c11a6b3440a Mon Sep 17 00:00:00 2001 From: Niklas Granander Date: Mon, 19 Jul 2021 15:40:05 +0200 Subject: [PATCH 10/16] Add trendline for build count and a few data fields Signed-off-by: Niklas Granander --- .../BuildTrendComponent.test.tsx | 31 +++++++ .../BuildTrendComponent.tsx | 48 +++++++++++ .../components/BuildTrendComponent/index.ts | 16 ++++ .../DataValueComponent.test.tsx | 40 +++++++++ .../DataValueComponent/DataValueComponent.tsx | 42 +++++++++ .../components/DataValueComponent/index.ts | 16 ++++ .../ErrorTrendComponent.test.tsx | 31 +++++++ .../ErrorTrendComponent.tsx | 39 ++++----- .../OverviewComponent.test.tsx | 32 ++++--- .../OverviewComponent/OverviewComponent.tsx | 57 ++++--------- .../OverviewTrendsComponent.test.tsx | 60 +++++++++++++ .../OverviewTrendsComponent.tsx | 85 +++++++++++++++++++ .../OverviewTrendsComponent/index.ts | 16 ++++ .../StatusCellComponent.test.tsx | 10 ++- .../StatusMatrixComponent.test.tsx | 6 +- .../src/test-utils/mockXcmetricsApi.ts | 9 +- plugins/xcmetrics/src/utils/format.ts | 12 ++- 17 files changed, 472 insertions(+), 78 deletions(-) create mode 100644 plugins/xcmetrics/src/components/BuildTrendComponent/BuildTrendComponent.test.tsx create mode 100644 plugins/xcmetrics/src/components/BuildTrendComponent/BuildTrendComponent.tsx create mode 100644 plugins/xcmetrics/src/components/BuildTrendComponent/index.ts create mode 100644 plugins/xcmetrics/src/components/DataValueComponent/DataValueComponent.test.tsx create mode 100644 plugins/xcmetrics/src/components/DataValueComponent/DataValueComponent.tsx create mode 100644 plugins/xcmetrics/src/components/DataValueComponent/index.ts create mode 100644 plugins/xcmetrics/src/components/ErrorTrendComponent/ErrorTrendComponent.test.tsx create mode 100644 plugins/xcmetrics/src/components/OverviewTrendsComponent/OverviewTrendsComponent.test.tsx create mode 100644 plugins/xcmetrics/src/components/OverviewTrendsComponent/OverviewTrendsComponent.tsx create mode 100644 plugins/xcmetrics/src/components/OverviewTrendsComponent/index.ts diff --git a/plugins/xcmetrics/src/components/BuildTrendComponent/BuildTrendComponent.test.tsx b/plugins/xcmetrics/src/components/BuildTrendComponent/BuildTrendComponent.test.tsx new file mode 100644 index 0000000000..2e96a0fb4e --- /dev/null +++ b/plugins/xcmetrics/src/components/BuildTrendComponent/BuildTrendComponent.test.tsx @@ -0,0 +1,31 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { BuildTrendComponent } from './BuildTrendComponent'; +import { renderInTestApp } from '@backstage/test-utils'; +import { BuildCount } from '../../api'; + +describe('BuildTrendComponent', () => { + it('should render', async () => { + const buildCounts: BuildCount[] = [ + { day: '2021-01-01', errors: 10, builds: 100 }, + ]; + const rendered = await renderInTestApp( + , + ); + expect(rendered.findAllByText('Build Count')).toBeTruthy(); + }); +}); diff --git a/plugins/xcmetrics/src/components/BuildTrendComponent/BuildTrendComponent.tsx b/plugins/xcmetrics/src/components/BuildTrendComponent/BuildTrendComponent.tsx new file mode 100644 index 0000000000..7403ec1c43 --- /dev/null +++ b/plugins/xcmetrics/src/components/BuildTrendComponent/BuildTrendComponent.tsx @@ -0,0 +1,48 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { TrendLine } from '@backstage/core-components'; +import { BuildCount } from '../../api'; +import { Typography, useTheme } from '@material-ui/core'; +import { BackstageTheme } from '@backstage/theme'; + +const TRENDLINE_TITLE = 'Build Count'; + +interface BuildTrendProps { + buildCounts: BuildCount[]; +} + +export const BuildTrendComponent = ({ buildCounts }: BuildTrendProps) => { + const theme = useTheme(); + + let max = 0; + const builds = buildCounts.map(counts => { + max = Math.max(max, counts.builds); + return counts.builds; + }); + + return ( + <> + {TRENDLINE_TITLE} + + + ); +}; diff --git a/plugins/xcmetrics/src/components/BuildTrendComponent/index.ts b/plugins/xcmetrics/src/components/BuildTrendComponent/index.ts new file mode 100644 index 0000000000..4f4ef09aca --- /dev/null +++ b/plugins/xcmetrics/src/components/BuildTrendComponent/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './BuildTrendComponent'; diff --git a/plugins/xcmetrics/src/components/DataValueComponent/DataValueComponent.test.tsx b/plugins/xcmetrics/src/components/DataValueComponent/DataValueComponent.test.tsx new file mode 100644 index 0000000000..3681b7cf20 --- /dev/null +++ b/plugins/xcmetrics/src/components/DataValueComponent/DataValueComponent.test.tsx @@ -0,0 +1,40 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { DataValueComponent, DataValueGridItem } from './DataValueComponent'; +import { renderInTestApp } from '@backstage/test-utils'; + +describe('DataValueComponent', () => { + it('should render', async () => { + const field = 'Field'; + const value = 'Value'; + const rendered = await renderInTestApp( + , + ); + expect(rendered.getByText(field)).toBeInTheDocument(); + expect(rendered.getByText(value)).toBeInTheDocument(); + }); + + it('grid item should render', async () => { + const field = 'Field'; + const value = 'Value'; + const rendered = await renderInTestApp( + , + ); + expect(rendered.getByText(field)).toBeInTheDocument(); + expect(rendered.getByText(value)).toBeInTheDocument(); + }); +}); diff --git a/plugins/xcmetrics/src/components/DataValueComponent/DataValueComponent.tsx b/plugins/xcmetrics/src/components/DataValueComponent/DataValueComponent.tsx new file mode 100644 index 0000000000..52e80d3291 --- /dev/null +++ b/plugins/xcmetrics/src/components/DataValueComponent/DataValueComponent.tsx @@ -0,0 +1,42 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Grid, GridSize, Typography } from '@material-ui/core'; +import React from 'react'; + +interface DataValueProps { + field: string; + value: string | number | undefined; +} + +export const DataValueComponent = ({ field, value }: DataValueProps) => { + return ( +
+ {field} + {value} +
+ ); +}; + +interface GridProps { + xs?: GridSize; + md?: GridSize; +} + +export const DataValueGridItem = (props: DataValueProps & GridProps) => ( + + + +); diff --git a/plugins/xcmetrics/src/components/DataValueComponent/index.ts b/plugins/xcmetrics/src/components/DataValueComponent/index.ts new file mode 100644 index 0000000000..cbf0ffe851 --- /dev/null +++ b/plugins/xcmetrics/src/components/DataValueComponent/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './DataValueComponent'; diff --git a/plugins/xcmetrics/src/components/ErrorTrendComponent/ErrorTrendComponent.test.tsx b/plugins/xcmetrics/src/components/ErrorTrendComponent/ErrorTrendComponent.test.tsx new file mode 100644 index 0000000000..67b2b0ce90 --- /dev/null +++ b/plugins/xcmetrics/src/components/ErrorTrendComponent/ErrorTrendComponent.test.tsx @@ -0,0 +1,31 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { ErrorTrendComponent } from './ErrorTrendComponent'; +import { renderInTestApp } from '@backstage/test-utils'; +import { BuildCount } from '../../api'; + +describe('ErrorTrendComponent', () => { + it('should render', async () => { + const buildCounts: BuildCount[] = [ + { day: '2021-01-01', errors: 10, builds: 100 }, + ]; + const rendered = await renderInTestApp( + , + ); + expect(rendered.findAllByText('Error Rate')).toBeTruthy(); + }); +}); diff --git a/plugins/xcmetrics/src/components/ErrorTrendComponent/ErrorTrendComponent.tsx b/plugins/xcmetrics/src/components/ErrorTrendComponent/ErrorTrendComponent.tsx index b8f358a903..d6bf789f5b 100644 --- a/plugins/xcmetrics/src/components/ErrorTrendComponent/ErrorTrendComponent.tsx +++ b/plugins/xcmetrics/src/components/ErrorTrendComponent/ErrorTrendComponent.tsx @@ -14,32 +14,19 @@ * limitations under the License. */ import React from 'react'; -import { Progress, TrendLine } from '@backstage/core-components'; -import { useApi } from '@backstage/core-plugin-api'; -import { BuildCount, xcmetricsApiRef } from '../../api'; -import { useAsync } from 'react-use'; -import { Alert } from '@material-ui/lab'; +import { TrendLine } from '@backstage/core-components'; +import { BuildCount } from '../../api'; +import { Typography, useTheme } from '@material-ui/core'; +import { BackstageTheme } from '@backstage/theme'; const TRENDLINE_TITLE = 'Error Rate'; interface ErrorTrendProps { - days: number; + buildCounts: BuildCount[]; } -export const ErrorTrendComponent = ({ days }: ErrorTrendProps) => { - const client = useApi(xcmetricsApiRef); - const { value: buildCounts, loading, error } = useAsync( - async (): Promise => client.getBuildCounts(days), - [], - ); - - if (loading) { - return ; - } else if (error) { - return {error.message}; - } else if (!buildCounts) { - return ; - } +export const ErrorTrendComponent = ({ buildCounts }: ErrorTrendProps) => { + const theme = useTheme(); let max = 0; const averageErrors = buildCounts.map(counts => { @@ -49,5 +36,15 @@ export const ErrorTrendComponent = ({ days }: ErrorTrendProps) => { return dayAverage; }); - return ; + return ( + <> + {TRENDLINE_TITLE} + + + ); }; diff --git a/plugins/xcmetrics/src/components/OverviewComponent/OverviewComponent.test.tsx b/plugins/xcmetrics/src/components/OverviewComponent/OverviewComponent.test.tsx index 545e9e8c1d..e5689869c1 100644 --- a/plugins/xcmetrics/src/components/OverviewComponent/OverviewComponent.test.tsx +++ b/plugins/xcmetrics/src/components/OverviewComponent/OverviewComponent.test.tsx @@ -14,47 +14,59 @@ * limitations under the License. */ import React from 'react'; -import { OverviewComponent } from './OverviewComponent'; import { renderInTestApp } from '@backstage/test-utils'; import { xcmetricsApiRef } from '../../api'; import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; -import { mockUserId, mockXcmetricsApi } from '../../test-utils'; +import { mockUserId, createMockXcmetricsApi } from '../../test-utils'; +import { OverviewComponent } from './OverviewComponent'; + +jest.mock('../OverviewTrendsComponent', () => ({ + OverviewTrendsComponent: () => 'OverviewTrendsComponent', +})); + +jest.mock('../StatusMatrixComponent', () => ({ + StatusMatrixComponent: () => 'StatusMatrixComponent', +})); describe('OverviewComponent', () => { it('should render', async () => { const rendered = await renderInTestApp( - + , ); + expect(rendered.getByText('XCMetrics Dashboard')).toBeInTheDocument(); expect(rendered.getByText(mockUserId)).toBeInTheDocument(); - expect(rendered.queryByText('CI')).toBeNull(); }); it('should render an empty state when no builds exist', async () => { - mockXcmetricsApi.getBuilds = jest.fn().mockResolvedValue([]); + const api = createMockXcmetricsApi(); + api.getBuilds = jest.fn().mockResolvedValue([]); const rendered = await renderInTestApp( - + , ); + expect(rendered.getByText('No builds to show')).toBeInTheDocument(); }); it('should show an error when API not responding', async () => { + const api = createMockXcmetricsApi(); const errorMessage = 'MockErrorMessage'; - mockXcmetricsApi.getBuilds = jest - .fn() - .mockRejectedValue({ message: errorMessage }); + api.getBuilds = jest.fn().mockRejectedValue({ message: errorMessage }); const rendered = await renderInTestApp( - + , ); + expect(rendered.getByText(errorMessage)).toBeInTheDocument(); }); }); diff --git a/plugins/xcmetrics/src/components/OverviewComponent/OverviewComponent.tsx b/plugins/xcmetrics/src/components/OverviewComponent/OverviewComponent.tsx index b9bd048ac1..3cb8b69be0 100644 --- a/plugins/xcmetrics/src/components/OverviewComponent/OverviewComponent.tsx +++ b/plugins/xcmetrics/src/components/OverviewComponent/OverviewComponent.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; +import React, { ReactChild } from 'react'; import { ContentHeader, SupportButton, @@ -31,33 +31,21 @@ import { Build, BuildStatus, xcmetricsApiRef } from '../../api'; import { useAsync } from 'react-use'; import { Alert } from '@material-ui/lab'; import { StatusMatrixComponent } from '../StatusMatrixComponent'; -import { formatDuration, formatStatus } from '../../utils'; -import { Chip, Grid, Typography } from '@material-ui/core'; -import { ErrorTrendComponent } from '../ErrorTrendComponent'; +import { formatTime } from '../../utils'; +import { Chip, Grid } from '@material-ui/core'; +import { OverviewTrendsComponent } from '../OverviewTrendsComponent'; -const Status = ({ - status, - warningCount, -}: { - status: BuildStatus; - warningCount: number; -}) => { - const statusIcons = { - succeeded: , - failed: , - stopped: , - }; - - return ( - <> - {statusIcons[status]} {formatStatus(status)} - {warningCount > 0 && ` with ${warningCount} warning`} - {warningCount > 1 && 's'} - - ); +const STATUS_ICONS: { [key in BuildStatus]: ReactChild } = { + succeeded: , + failed: , + stopped: , }; const columns: TableColumn[] = [ + { + field: 'buildStatus', + render: data => STATUS_ICONS[data.buildStatus], + }, { title: 'Project', field: 'projectName', @@ -67,23 +55,15 @@ const columns: TableColumn[] = [ field: 'schema', }, { - title: 'Duration', - field: 'duration', - type: 'time', + title: 'Started', + field: 'startedAt', searchable: false, - render: data => formatDuration(data.duration), + render: data => formatTime(data.startTimestamp), }, { title: 'User', field: 'userid', }, - { - title: 'Status', - field: 'buildStatus', - render: data => ( - - ), - }, { field: 'isCI', render: data => data.isCi && , @@ -121,7 +101,7 @@ export const OverviewComponent = () => { Dashboard for XCMetrics - + { } /> - + - Error Rate - + diff --git a/plugins/xcmetrics/src/components/OverviewTrendsComponent/OverviewTrendsComponent.test.tsx b/plugins/xcmetrics/src/components/OverviewTrendsComponent/OverviewTrendsComponent.test.tsx new file mode 100644 index 0000000000..957602384d --- /dev/null +++ b/plugins/xcmetrics/src/components/OverviewTrendsComponent/OverviewTrendsComponent.test.tsx @@ -0,0 +1,60 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { OverviewTrendsComponent } from './OverviewTrendsComponent'; +import { renderInTestApp } from '@backstage/test-utils'; +import { xcmetricsApiRef } from '../../api'; +import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { createMockXcmetricsApi } from '../../test-utils'; + +describe('OverviewTrendsComponent', () => { + it('should render', async () => { + const rendered = await renderInTestApp( + + + , + ); + expect(rendered.getByText('Last 14 Days')).toBeInTheDocument(); + }); + + it('should render empty state', async () => { + const api = createMockXcmetricsApi(); + api.getBuildCounts = jest.fn().mockResolvedValue([]); + + const rendered = await renderInTestApp( + + + , + ); + expect(rendered.getByText('No Trends Available')).toBeInTheDocument(); + }); + + it('should show an error when API not responding', async () => { + const api = createMockXcmetricsApi(); + const errorMessage = 'MockErrorMessage'; + + api.getBuildCounts = jest.fn().mockRejectedValue({ message: errorMessage }); + + const rendered = await renderInTestApp( + + + , + ); + expect(rendered.getByText(errorMessage)).toBeInTheDocument(); + }); +}); diff --git a/plugins/xcmetrics/src/components/OverviewTrendsComponent/OverviewTrendsComponent.tsx b/plugins/xcmetrics/src/components/OverviewTrendsComponent/OverviewTrendsComponent.tsx new file mode 100644 index 0000000000..cbc64a902e --- /dev/null +++ b/plugins/xcmetrics/src/components/OverviewTrendsComponent/OverviewTrendsComponent.tsx @@ -0,0 +1,85 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Grid, makeStyles, Typography } from '@material-ui/core'; +import React from 'react'; +import { Progress, TrendLine } from '@backstage/core-components'; +import { ErrorTrendComponent } from '../ErrorTrendComponent'; +import { Alert } from '@material-ui/lab'; +import { BuildCount, xcmetricsApiRef } from '../../api'; +import { useAsync } from 'react-use'; +import { useApi } from '@backstage/core-plugin-api'; +import { BuildTrendComponent } from '../BuildTrendComponent'; +import { DataValueGridItem } from '../DataValueComponent'; +import { formatPercentage } from '../../utils'; + +const useStyles = makeStyles({ + spacingTop: { + marginTop: 8, + }, +}); + +export const OverviewTrendsComponent = ({ days }: { days: number }) => { + const classes = useStyles(); + const client = useApi(xcmetricsApiRef); + const { value: buildCounts, loading, error } = useAsync( + async (): Promise => client.getBuildCounts(days), + [], + ); + + if (loading) { + return ; + } else if (error) { + return {error.message}; + } else if (!buildCounts || buildCounts.length === 0) { + return ( + <> + No Trends Available + + + ); + } + + const sumCount = buildCounts.reduce( + (sum, current) => sum + current.builds, + 0, + ); + const sumErrors = buildCounts.reduce( + (sum, current) => sum + current.errors, + 0, + ); + const errorRate = sumCount > 0 ? sumErrors / sumCount : 0; + + return ( + <> + Last {days} Days + + + + + + + + + ); +}; diff --git a/plugins/xcmetrics/src/components/OverviewTrendsComponent/index.ts b/plugins/xcmetrics/src/components/OverviewTrendsComponent/index.ts new file mode 100644 index 0000000000..778a4bf503 --- /dev/null +++ b/plugins/xcmetrics/src/components/OverviewTrendsComponent/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './OverviewTrendsComponent'; diff --git a/plugins/xcmetrics/src/components/StatusCellComponent/StatusCellComponent.test.tsx b/plugins/xcmetrics/src/components/StatusCellComponent/StatusCellComponent.test.tsx index 470f69fa8d..363486eb51 100644 --- a/plugins/xcmetrics/src/components/StatusCellComponent/StatusCellComponent.test.tsx +++ b/plugins/xcmetrics/src/components/StatusCellComponent/StatusCellComponent.test.tsx @@ -19,13 +19,19 @@ import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import userEvent from '@testing-library/user-event'; import { StatusCellComponent } from './StatusCellComponent'; import { xcmetricsApiRef } from '../../api'; -import { mockBuildId, mockStatus, mockXcmetricsApi } from '../../test-utils'; +import { + mockBuildId, + mockStatus, + createMockXcmetricsApi, +} from '../../test-utils'; import { formatStatus } from '../../utils'; describe('StatusCellComponent', () => { it('should render', async () => { const rendered = await renderInTestApp( - + { it('should render', async () => { const rendered = await renderInTestApp( - + , ); diff --git a/plugins/xcmetrics/src/test-utils/mockXcmetricsApi.ts b/plugins/xcmetrics/src/test-utils/mockXcmetricsApi.ts index 58419dfd48..b73b361926 100644 --- a/plugins/xcmetrics/src/test-utils/mockXcmetricsApi.ts +++ b/plugins/xcmetrics/src/test-utils/mockXcmetricsApi.ts @@ -19,7 +19,7 @@ export const mockUserId = 'user_id'; export const mockBuildId = 'build_id'; export const mockStatus = 'succeeded'; -export const mockXcmetricsApi: jest.Mocked = { +export const createMockXcmetricsApi = (): jest.Mocked => ({ getBuildStatuses: jest .fn() .mockResolvedValue([{ id: mockBuildId, status: mockStatus }]), @@ -40,5 +40,8 @@ export const mockXcmetricsApi: jest.Mocked = { schema: 'AppSchema', }, ]), - getBuildCounts: jest.fn(), -}; + getBuildCounts: jest.fn().mockResolvedValue([ + { day: '2021-07-10', builds: 10, errors: 1 }, + { day: '2021-07-09', builds: 11, errors: 2 }, + ]), +}); diff --git a/plugins/xcmetrics/src/utils/format.ts b/plugins/xcmetrics/src/utils/format.ts index d8c08d9367..20d8cc68db 100644 --- a/plugins/xcmetrics/src/utils/format.ts +++ b/plugins/xcmetrics/src/utils/format.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Duration } from 'luxon'; +import { DateTime, Duration } from 'luxon'; import { BuildStatus } from '../api'; export const formatDuration = (seconds: number) => @@ -21,5 +21,15 @@ export const formatDuration = (seconds: number) => suppressMilliseconds: true, }); +export const formatTime = (timestamp: string) => { + return DateTime.fromISO(timestamp).toLocaleString( + DateTime.DATETIME_SHORT_WITH_SECONDS, + ); +}; + +export const formatPercentage = (number: number) => { + return `${Math.round(number * 100)} %`; +}; + export const formatStatus = (status: BuildStatus) => status[0].toUpperCase() + status.slice(1); From 2627f19b7b52fcc1ae451af45b7dfa92b8e0c9a0 Mon Sep 17 00:00:00 2001 From: Niklas Granander Date: Tue, 20 Jul 2021 15:32:31 +0200 Subject: [PATCH 11/16] Add placeholder text to DataValueComponent Signed-off-by: Niklas Granander --- .../DataValueComponent/DataValueComponent.test.tsx | 9 +++++++++ .../components/DataValueComponent/DataValueComponent.tsx | 4 ++-- .../OverviewTrendsComponent/OverviewTrendsComponent.tsx | 4 ++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/plugins/xcmetrics/src/components/DataValueComponent/DataValueComponent.test.tsx b/plugins/xcmetrics/src/components/DataValueComponent/DataValueComponent.test.tsx index 3681b7cf20..68c32a1a3d 100644 --- a/plugins/xcmetrics/src/components/DataValueComponent/DataValueComponent.test.tsx +++ b/plugins/xcmetrics/src/components/DataValueComponent/DataValueComponent.test.tsx @@ -28,6 +28,15 @@ describe('DataValueComponent', () => { expect(rendered.getByText(value)).toBeInTheDocument(); }); + it('should render placeholder text when no value is present', async () => { + const field = 'Field'; + const rendered = await renderInTestApp( + , + ); + expect(rendered.getByText(field)).toBeInTheDocument(); + expect(rendered.getByText('Unknown')).toBeInTheDocument(); + }); + it('grid item should render', async () => { const field = 'Field'; const value = 'Value'; diff --git a/plugins/xcmetrics/src/components/DataValueComponent/DataValueComponent.tsx b/plugins/xcmetrics/src/components/DataValueComponent/DataValueComponent.tsx index 52e80d3291..d29dfe3213 100644 --- a/plugins/xcmetrics/src/components/DataValueComponent/DataValueComponent.tsx +++ b/plugins/xcmetrics/src/components/DataValueComponent/DataValueComponent.tsx @@ -18,14 +18,14 @@ import React from 'react'; interface DataValueProps { field: string; - value: string | number | undefined; + value?: string | number | null | undefined; } export const DataValueComponent = ({ field, value }: DataValueProps) => { return (
{field} - {value} + {value ?? 'Unknown'}
); }; diff --git a/plugins/xcmetrics/src/components/OverviewTrendsComponent/OverviewTrendsComponent.tsx b/plugins/xcmetrics/src/components/OverviewTrendsComponent/OverviewTrendsComponent.tsx index cbc64a902e..5dff52c2db 100644 --- a/plugins/xcmetrics/src/components/OverviewTrendsComponent/OverviewTrendsComponent.tsx +++ b/plugins/xcmetrics/src/components/OverviewTrendsComponent/OverviewTrendsComponent.tsx @@ -73,8 +73,8 @@ export const OverviewTrendsComponent = ({ days }: { days: number }) => { direction="row" className={classes.spacingTop} > - - + + Date: Tue, 27 Jul 2021 17:25:03 +0200 Subject: [PATCH 12/16] Add duration data to overview Signed-off-by: Niklas Granander --- plugins/xcmetrics/src/api/XcmetricsClient.ts | 14 ++ plugins/xcmetrics/src/api/types.ts | 8 + .../components/BuildTrendComponent/index.ts | 16 -- .../DataValueComponent.test.tsx | 2 +- .../DataValueComponent/DataValueComponent.tsx | 5 +- .../ErrorTrendComponent.test.tsx | 31 ---- .../ErrorTrendComponent.tsx | 50 ----- .../OverviewTrendsComponent.test.tsx | 19 +- .../OverviewTrendsComponent.tsx | 174 ++++++++++++++---- .../TrendComponent.test.tsx} | 22 ++- .../TrendComponent.tsx} | 31 ++-- .../index.ts | 2 +- .../src/test-utils/mockXcmetricsApi.ts | 14 ++ plugins/xcmetrics/src/utils/format.ts | 16 +- 14 files changed, 230 insertions(+), 174 deletions(-) delete mode 100644 plugins/xcmetrics/src/components/BuildTrendComponent/index.ts delete mode 100644 plugins/xcmetrics/src/components/ErrorTrendComponent/ErrorTrendComponent.test.tsx delete mode 100644 plugins/xcmetrics/src/components/ErrorTrendComponent/ErrorTrendComponent.tsx rename plugins/xcmetrics/src/components/{BuildTrendComponent/BuildTrendComponent.test.tsx => TrendComponent/TrendComponent.test.tsx} (59%) rename plugins/xcmetrics/src/components/{BuildTrendComponent/BuildTrendComponent.tsx => TrendComponent/TrendComponent.tsx} (53%) rename plugins/xcmetrics/src/components/{ErrorTrendComponent => TrendComponent}/index.ts (93%) diff --git a/plugins/xcmetrics/src/api/XcmetricsClient.ts b/plugins/xcmetrics/src/api/XcmetricsClient.ts index 998244f967..0d4ed0dee1 100644 --- a/plugins/xcmetrics/src/api/XcmetricsClient.ts +++ b/plugins/xcmetrics/src/api/XcmetricsClient.ts @@ -20,6 +20,7 @@ import { Build, BuildCount, BuildStatusResult, + BuildTime, PaginationResult, XcmetricsApi, } from './types'; @@ -70,6 +71,19 @@ export class XcmetricsClient implements XcmetricsApi { return (await response.json()) as BuildCount[]; } + async getBuildTimes(days: number): Promise { + const baseUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/xcmetrics`; + const response = await fetch( + `${baseUrl}/statistics/build/time?days=${days}`, + ); + + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + + return (await response.json()) as BuildTime[]; + } + async getBuildStatuses(limit: number): Promise { const baseUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/xcmetrics`; const response = await fetch( diff --git a/plugins/xcmetrics/src/api/types.ts b/plugins/xcmetrics/src/api/types.ts index 487da5065d..48bee5fd38 100644 --- a/plugins/xcmetrics/src/api/types.ts +++ b/plugins/xcmetrics/src/api/types.ts @@ -52,6 +52,13 @@ export type BuildCount = { builds: number; }; +export type BuildTime = { + day: string; + durationP50: number; + durationP95: number; + totalDuration: number; +}; + export type PaginationResult = { items: T[]; metadata: { @@ -65,6 +72,7 @@ export interface XcmetricsApi { getBuild(id: string): Promise; getBuilds(): Promise; getBuildCounts(days: number): Promise; + getBuildTimes(days: number): Promise; getBuildStatuses(limit: number): Promise; } diff --git a/plugins/xcmetrics/src/components/BuildTrendComponent/index.ts b/plugins/xcmetrics/src/components/BuildTrendComponent/index.ts deleted file mode 100644 index 4f4ef09aca..0000000000 --- a/plugins/xcmetrics/src/components/BuildTrendComponent/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright 2021 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -export * from './BuildTrendComponent'; diff --git a/plugins/xcmetrics/src/components/DataValueComponent/DataValueComponent.test.tsx b/plugins/xcmetrics/src/components/DataValueComponent/DataValueComponent.test.tsx index 68c32a1a3d..df52d020ee 100644 --- a/plugins/xcmetrics/src/components/DataValueComponent/DataValueComponent.test.tsx +++ b/plugins/xcmetrics/src/components/DataValueComponent/DataValueComponent.test.tsx @@ -34,7 +34,7 @@ describe('DataValueComponent', () => { , ); expect(rendered.getByText(field)).toBeInTheDocument(); - expect(rendered.getByText('Unknown')).toBeInTheDocument(); + expect(rendered.getByText('--')).toBeInTheDocument(); }); it('grid item should render', async () => { diff --git a/plugins/xcmetrics/src/components/DataValueComponent/DataValueComponent.tsx b/plugins/xcmetrics/src/components/DataValueComponent/DataValueComponent.tsx index d29dfe3213..35b0331001 100644 --- a/plugins/xcmetrics/src/components/DataValueComponent/DataValueComponent.tsx +++ b/plugins/xcmetrics/src/components/DataValueComponent/DataValueComponent.tsx @@ -25,7 +25,7 @@ export const DataValueComponent = ({ field, value }: DataValueProps) => { return (
{field} - {value ?? 'Unknown'} + {value ?? '--'}
); }; @@ -33,10 +33,11 @@ export const DataValueComponent = ({ field, value }: DataValueProps) => { interface GridProps { xs?: GridSize; md?: GridSize; + lg?: GridSize; } export const DataValueGridItem = (props: DataValueProps & GridProps) => ( - + ); diff --git a/plugins/xcmetrics/src/components/ErrorTrendComponent/ErrorTrendComponent.test.tsx b/plugins/xcmetrics/src/components/ErrorTrendComponent/ErrorTrendComponent.test.tsx deleted file mode 100644 index 67b2b0ce90..0000000000 --- a/plugins/xcmetrics/src/components/ErrorTrendComponent/ErrorTrendComponent.test.tsx +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright 2021 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React from 'react'; -import { ErrorTrendComponent } from './ErrorTrendComponent'; -import { renderInTestApp } from '@backstage/test-utils'; -import { BuildCount } from '../../api'; - -describe('ErrorTrendComponent', () => { - it('should render', async () => { - const buildCounts: BuildCount[] = [ - { day: '2021-01-01', errors: 10, builds: 100 }, - ]; - const rendered = await renderInTestApp( - , - ); - expect(rendered.findAllByText('Error Rate')).toBeTruthy(); - }); -}); diff --git a/plugins/xcmetrics/src/components/ErrorTrendComponent/ErrorTrendComponent.tsx b/plugins/xcmetrics/src/components/ErrorTrendComponent/ErrorTrendComponent.tsx deleted file mode 100644 index d6bf789f5b..0000000000 --- a/plugins/xcmetrics/src/components/ErrorTrendComponent/ErrorTrendComponent.tsx +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright 2021 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React from 'react'; -import { TrendLine } from '@backstage/core-components'; -import { BuildCount } from '../../api'; -import { Typography, useTheme } from '@material-ui/core'; -import { BackstageTheme } from '@backstage/theme'; - -const TRENDLINE_TITLE = 'Error Rate'; - -interface ErrorTrendProps { - buildCounts: BuildCount[]; -} - -export const ErrorTrendComponent = ({ buildCounts }: ErrorTrendProps) => { - const theme = useTheme(); - - let max = 0; - const averageErrors = buildCounts.map(counts => { - if (counts.builds === 0) return 0; - const dayAverage = counts.errors / counts.builds; - max = Math.max(max, dayAverage); - return dayAverage; - }); - - return ( - <> - {TRENDLINE_TITLE} - - - ); -}; diff --git a/plugins/xcmetrics/src/components/OverviewTrendsComponent/OverviewTrendsComponent.test.tsx b/plugins/xcmetrics/src/components/OverviewTrendsComponent/OverviewTrendsComponent.test.tsx index 957602384d..a25f64c685 100644 --- a/plugins/xcmetrics/src/components/OverviewTrendsComponent/OverviewTrendsComponent.test.tsx +++ b/plugins/xcmetrics/src/components/OverviewTrendsComponent/OverviewTrendsComponent.test.tsx @@ -30,6 +30,8 @@ describe('OverviewTrendsComponent', () => {
, ); expect(rendered.getByText('Last 14 Days')).toBeInTheDocument(); + expect(rendered.getAllByText('Build Count').length).toEqual(3); + expect(rendered.getByText('Avg. Build Time (P50)')).toBeInTheDocument(); }); it('should render empty state', async () => { @@ -41,20 +43,27 @@ describe('OverviewTrendsComponent', () => {
, ); - expect(rendered.getByText('No Trends Available')).toBeInTheDocument(); + expect(rendered.getByText('--')).toBeInTheDocument(); }); - it('should show an error when API not responding', async () => { + it('should show errors when API not responding', async () => { const api = createMockXcmetricsApi(); - const errorMessage = 'MockErrorMessage'; + const buildCountError = 'MockBuildCountErrorMessage'; + const buildTimesError = 'MockBuildTimesErrorMessage'; - api.getBuildCounts = jest.fn().mockRejectedValue({ message: errorMessage }); + api.getBuildCounts = jest + .fn() + .mockRejectedValue({ message: buildCountError }); + api.getBuildTimes = jest + .fn() + .mockRejectedValue({ message: buildTimesError }); const rendered = await renderInTestApp( , ); - expect(rendered.getByText(errorMessage)).toBeInTheDocument(); + expect(rendered.getByText(buildCountError)).toBeInTheDocument(); + expect(rendered.getByText(buildTimesError)).toBeInTheDocument(); }); }); diff --git a/plugins/xcmetrics/src/components/OverviewTrendsComponent/OverviewTrendsComponent.tsx b/plugins/xcmetrics/src/components/OverviewTrendsComponent/OverviewTrendsComponent.tsx index 5dff52c2db..a7f46d1851 100644 --- a/plugins/xcmetrics/src/components/OverviewTrendsComponent/OverviewTrendsComponent.tsx +++ b/plugins/xcmetrics/src/components/OverviewTrendsComponent/OverviewTrendsComponent.tsx @@ -13,73 +13,173 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Grid, makeStyles, Typography } from '@material-ui/core'; +import { Grid, makeStyles, Typography, useTheme } from '@material-ui/core'; import React from 'react'; -import { Progress, TrendLine } from '@backstage/core-components'; -import { ErrorTrendComponent } from '../ErrorTrendComponent'; -import { Alert } from '@material-ui/lab'; -import { BuildCount, xcmetricsApiRef } from '../../api'; +import { Progress } from '@backstage/core-components'; +import { TrendComponent } from '../TrendComponent'; +import { Alert, AlertTitle } from '@material-ui/lab'; +import { BuildCount, BuildTime, xcmetricsApiRef } from '../../api'; import { useAsync } from 'react-use'; import { useApi } from '@backstage/core-plugin-api'; -import { BuildTrendComponent } from '../BuildTrendComponent'; import { DataValueGridItem } from '../DataValueComponent'; -import { formatPercentage } from '../../utils'; +import { formatDuration, formatPercentage } from '../../utils'; +import { BackstageTheme } from '@backstage/theme'; + +const getErrorRatios = (buildCounts?: BuildCount[]) => { + if (!buildCounts?.length) { + return undefined; + } + + return buildCounts.map(counts => + counts.builds === 0 ? 0 : counts.errors / counts.builds, + ); +}; + +const getBuildCounts = (buildCounts?: BuildCount[]) => { + if (!buildCounts?.length) { + return undefined; + } + + return buildCounts.map(counts => counts.builds); +}; + +const getBuildDurationsP50 = (buildTimes?: BuildTime[]) => { + if (!buildTimes?.length) { + return undefined; + } + + return buildTimes.map(times => times.durationP50); +}; + +const getAverageDuration = ( + buildTimes: BuildTime[] | undefined, + accessor: (b: BuildTime) => number, +) => { + if (!buildTimes?.length) { + return undefined; + } + + return formatDuration( + buildTimes.reduce((sum, current) => sum + accessor(current), 0) / + buildTimes.length, + ); +}; + +const getTotalBuildDuration = (buildTimes?: BuildTime[]) => { + if (!buildTimes?.length) { + return undefined; + } + + return formatDuration( + buildTimes.reduce((sum, current) => sum + current.totalDuration, 0), + ); +}; const useStyles = makeStyles({ spacingTop: { marginTop: 8, }, + spacingVertical: { + marginTop: 8, + marginBottom: 8, + }, }); export const OverviewTrendsComponent = ({ days }: { days: number }) => { + const theme = useTheme(); const classes = useStyles(); const client = useApi(xcmetricsApiRef); - const { value: buildCounts, loading, error } = useAsync( - async (): Promise => client.getBuildCounts(days), + const buildCountsResult = useAsync( + async () => client.getBuildCounts(days), [], ); + const buildTimesResult = useAsync(async () => client.getBuildTimes(days), []); - if (loading) { + if (buildCountsResult.loading && buildTimesResult.loading) { return ; - } else if (error) { - return {error.message}; - } else if (!buildCounts || buildCounts.length === 0) { - return ( - <> - No Trends Available - - - ); } - const sumCount = buildCounts.reduce( + const sumBuilds = buildCountsResult.value?.reduce( (sum, current) => sum + current.builds, 0, ); - const sumErrors = buildCounts.reduce( + + const sumErrors = buildCountsResult.value?.reduce( (sum, current) => sum + current.errors, 0, ); - const errorRate = sumCount > 0 ? sumErrors / sumCount : 0; + + const errorRate = sumBuilds && sumErrors ? sumErrors / sumBuilds : undefined; + + const averageBuildDurationP50 = getAverageDuration( + buildTimesResult.value, + b => b.durationP50, + ); + const averageBuildDurationP95 = getAverageDuration( + buildTimesResult.value, + b => b.durationP95, + ); + const totalBuildTime = getTotalBuildDuration(buildTimesResult.value); return ( <> Last {days} Days - - - - - - - + {buildCountsResult.error && ( + + Failed to fetch build counts + {buildCountsResult?.error?.message} + + )} + {buildTimesResult.error && ( + + Failed to fetch build times + {buildTimesResult?.error?.message} + + )} + {(!buildCountsResult.error || !buildTimesResult.error) && ( + <> + + + + + + + + + + + + + )} ); }; diff --git a/plugins/xcmetrics/src/components/BuildTrendComponent/BuildTrendComponent.test.tsx b/plugins/xcmetrics/src/components/TrendComponent/TrendComponent.test.tsx similarity index 59% rename from plugins/xcmetrics/src/components/BuildTrendComponent/BuildTrendComponent.test.tsx rename to plugins/xcmetrics/src/components/TrendComponent/TrendComponent.test.tsx index 2e96a0fb4e..0a1b0e0b32 100644 --- a/plugins/xcmetrics/src/components/BuildTrendComponent/BuildTrendComponent.test.tsx +++ b/plugins/xcmetrics/src/components/TrendComponent/TrendComponent.test.tsx @@ -14,18 +14,24 @@ * limitations under the License. */ import React from 'react'; -import { BuildTrendComponent } from './BuildTrendComponent'; +import { TrendComponent } from './TrendComponent'; import { renderInTestApp } from '@backstage/test-utils'; -import { BuildCount } from '../../api'; -describe('BuildTrendComponent', () => { +describe('TrendComponent', () => { it('should render', async () => { - const buildCounts: BuildCount[] = [ - { day: '2021-01-01', errors: 10, builds: 100 }, - ]; + const data = [1, 2, 3, 4]; + const title = 'testTitle'; const rendered = await renderInTestApp( - , + , ); - expect(rendered.findAllByText('Build Count')).toBeTruthy(); + expect(rendered.findAllByText('testTitle')).toBeTruthy(); + }); + + it('should render empty state', async () => { + const title = 'testTitle'; + const rendered = await renderInTestApp( + , + ); + expect(rendered.findAllByText('testTitle')).toBeTruthy(); }); }); diff --git a/plugins/xcmetrics/src/components/BuildTrendComponent/BuildTrendComponent.tsx b/plugins/xcmetrics/src/components/TrendComponent/TrendComponent.tsx similarity index 53% rename from plugins/xcmetrics/src/components/BuildTrendComponent/BuildTrendComponent.tsx rename to plugins/xcmetrics/src/components/TrendComponent/TrendComponent.tsx index 7403ec1c43..ece6acfd6c 100644 --- a/plugins/xcmetrics/src/components/BuildTrendComponent/BuildTrendComponent.tsx +++ b/plugins/xcmetrics/src/components/TrendComponent/TrendComponent.tsx @@ -15,33 +15,26 @@ */ import React from 'react'; import { TrendLine } from '@backstage/core-components'; -import { BuildCount } from '../../api'; -import { Typography, useTheme } from '@material-ui/core'; -import { BackstageTheme } from '@backstage/theme'; +import { Typography } from '@material-ui/core'; -const TRENDLINE_TITLE = 'Build Count'; - -interface BuildTrendProps { - buildCounts: BuildCount[]; +interface TrendProps { + data?: number[]; + title: string; + color: string; } -export const BuildTrendComponent = ({ buildCounts }: BuildTrendProps) => { - const theme = useTheme(); - - let max = 0; - const builds = buildCounts.map(counts => { - max = Math.max(max, counts.builds); - return counts.builds; - }); +export const TrendComponent = ({ data, title, color }: TrendProps) => { + const emptyData = [0, 0]; + const max = Math.max(...(data ?? emptyData)); return ( <> - {TRENDLINE_TITLE} + {title} ); diff --git a/plugins/xcmetrics/src/components/ErrorTrendComponent/index.ts b/plugins/xcmetrics/src/components/TrendComponent/index.ts similarity index 93% rename from plugins/xcmetrics/src/components/ErrorTrendComponent/index.ts rename to plugins/xcmetrics/src/components/TrendComponent/index.ts index 974f40a8ae..69dce3ff7f 100644 --- a/plugins/xcmetrics/src/components/ErrorTrendComponent/index.ts +++ b/plugins/xcmetrics/src/components/TrendComponent/index.ts @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export * from './ErrorTrendComponent'; +export * from './TrendComponent'; diff --git a/plugins/xcmetrics/src/test-utils/mockXcmetricsApi.ts b/plugins/xcmetrics/src/test-utils/mockXcmetricsApi.ts index b73b361926..08e75f2653 100644 --- a/plugins/xcmetrics/src/test-utils/mockXcmetricsApi.ts +++ b/plugins/xcmetrics/src/test-utils/mockXcmetricsApi.ts @@ -44,4 +44,18 @@ export const createMockXcmetricsApi = (): jest.Mocked => ({ { day: '2021-07-10', builds: 10, errors: 1 }, { day: '2021-07-09', builds: 11, errors: 2 }, ]), + getBuildTimes: jest.fn().mockResolvedValue([ + { + day: '2021-07-10', + durationP50: 1.1, + durationP95: 2.1, + totalDuration: 3.1, + }, + { + day: '2021-07-09', + durationP50: 1.2, + durationP95: 2.2, + totalDuration: 3.2, + }, + ]), }); diff --git a/plugins/xcmetrics/src/utils/format.ts b/plugins/xcmetrics/src/utils/format.ts index 20d8cc68db..085441bb84 100644 --- a/plugins/xcmetrics/src/utils/format.ts +++ b/plugins/xcmetrics/src/utils/format.ts @@ -16,10 +16,18 @@ import { DateTime, Duration } from 'luxon'; import { BuildStatus } from '../api'; -export const formatDuration = (seconds: number) => - Duration.fromObject({ seconds: Math.round(seconds) }).toISOTime({ - suppressMilliseconds: true, - }); +export const formatDuration = (seconds: number) => { + const duration = Duration.fromObject({ + seconds: Math.round(seconds), + }).shiftTo('hours', 'minutes', 'seconds'); + + const h = duration.hours ? `${duration.hours} h` : ''; + const m = duration.minutes ? `${duration.minutes} m` : ''; + const s = + duration.hours < 12 && duration.seconds ? `${duration.seconds} s` : ''; + + return `${h} ${m} ${s}`; +}; export const formatTime = (timestamp: string) => { return DateTime.fromISO(timestamp).toLocaleString( From 70b18d97d6eb055b72fdaa680c1868b5be1a2d83 Mon Sep 17 00:00:00 2001 From: Niklas Granander Date: Tue, 27 Jul 2021 19:48:15 +0200 Subject: [PATCH 13/16] Allow user to select the number of days for trend data Signed-off-by: Niklas Granander --- .../OverviewComponent/OverviewComponent.tsx | 13 +- .../OverviewTrendsComponent.test.tsx | 23 +++- .../OverviewTrendsComponent.tsx | 119 ++++++------------ plugins/xcmetrics/src/utils/array.ts | 33 +++++ plugins/xcmetrics/src/utils/buildData.ts | 42 +++++++ plugins/xcmetrics/src/utils/index.ts | 2 + plugins/xcmetrics/src/utils/utils.test.ts | 53 +++++++- 7 files changed, 198 insertions(+), 87 deletions(-) create mode 100644 plugins/xcmetrics/src/utils/array.ts create mode 100644 plugins/xcmetrics/src/utils/buildData.ts diff --git a/plugins/xcmetrics/src/components/OverviewComponent/OverviewComponent.tsx b/plugins/xcmetrics/src/components/OverviewComponent/OverviewComponent.tsx index 3cb8b69be0..cee8e2a549 100644 --- a/plugins/xcmetrics/src/components/OverviewComponent/OverviewComponent.tsx +++ b/plugins/xcmetrics/src/components/OverviewComponent/OverviewComponent.tsx @@ -31,7 +31,7 @@ import { Build, BuildStatus, xcmetricsApiRef } from '../../api'; import { useAsync } from 'react-use'; import { Alert } from '@material-ui/lab'; import { StatusMatrixComponent } from '../StatusMatrixComponent'; -import { formatTime } from '../../utils'; +import { formatDuration, formatTime } from '../../utils'; import { Chip, Grid } from '@material-ui/core'; import { OverviewTrendsComponent } from '../OverviewTrendsComponent'; @@ -60,6 +60,11 @@ const columns: TableColumn[] = [ searchable: false, render: data => formatTime(data.startTimestamp), }, + { + title: 'Duration', + field: 'duration', + render: data => formatDuration(data.duration), + }, { title: 'User', field: 'userid', @@ -101,7 +106,7 @@ export const OverviewComponent = () => { Dashboard for XCMetrics - +
{ } /> - + - + diff --git a/plugins/xcmetrics/src/components/OverviewTrendsComponent/OverviewTrendsComponent.test.tsx b/plugins/xcmetrics/src/components/OverviewTrendsComponent/OverviewTrendsComponent.test.tsx index a25f64c685..ddde3952b3 100644 --- a/plugins/xcmetrics/src/components/OverviewTrendsComponent/OverviewTrendsComponent.test.tsx +++ b/plugins/xcmetrics/src/components/OverviewTrendsComponent/OverviewTrendsComponent.test.tsx @@ -19,6 +19,7 @@ import { renderInTestApp } from '@backstage/test-utils'; import { xcmetricsApiRef } from '../../api'; import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import { createMockXcmetricsApi } from '../../test-utils'; +import userEvent from '@testing-library/user-event'; describe('OverviewTrendsComponent', () => { it('should render', async () => { @@ -26,10 +27,10 @@ describe('OverviewTrendsComponent', () => { - + , ); - expect(rendered.getByText('Last 14 Days')).toBeInTheDocument(); + expect(rendered.getByText('Trends for')).toBeInTheDocument(); expect(rendered.getAllByText('Build Count').length).toEqual(3); expect(rendered.getByText('Avg. Build Time (P50)')).toBeInTheDocument(); }); @@ -40,12 +41,26 @@ describe('OverviewTrendsComponent', () => { const rendered = await renderInTestApp( - + , ); expect(rendered.getByText('--')).toBeInTheDocument(); }); + it('should change number of days when select is changed', async () => { + const rendered = await renderInTestApp( + + + , + ); + + userEvent.click(rendered.getByText('14 days')); + userEvent.click(await rendered.findByText('30 days')); + expect(await rendered.findByText('30 days')).toBeInTheDocument(); + }); + it('should show errors when API not responding', async () => { const api = createMockXcmetricsApi(); const buildCountError = 'MockBuildCountErrorMessage'; @@ -60,7 +75,7 @@ describe('OverviewTrendsComponent', () => { const rendered = await renderInTestApp( - + , ); expect(rendered.getByText(buildCountError)).toBeInTheDocument(); diff --git a/plugins/xcmetrics/src/components/OverviewTrendsComponent/OverviewTrendsComponent.tsx b/plugins/xcmetrics/src/components/OverviewTrendsComponent/OverviewTrendsComponent.tsx index a7f46d1851..2c4a04d582 100644 --- a/plugins/xcmetrics/src/components/OverviewTrendsComponent/OverviewTrendsComponent.tsx +++ b/plugins/xcmetrics/src/components/OverviewTrendsComponent/OverviewTrendsComponent.tsx @@ -13,68 +13,25 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Grid, makeStyles, Typography, useTheme } from '@material-ui/core'; -import React from 'react'; -import { Progress } from '@backstage/core-components'; +import { Grid, makeStyles, useTheme } from '@material-ui/core'; +import React, { useState } from 'react'; +import { Progress, Select } from '@backstage/core-components'; import { TrendComponent } from '../TrendComponent'; import { Alert, AlertTitle } from '@material-ui/lab'; -import { BuildCount, BuildTime, xcmetricsApiRef } from '../../api'; +import { xcmetricsApiRef } from '../../api'; import { useAsync } from 'react-use'; import { useApi } from '@backstage/core-plugin-api'; import { DataValueGridItem } from '../DataValueComponent'; -import { formatDuration, formatPercentage } from '../../utils'; +import { + formatDuration, + formatPercentage, + getAverageDuration, + getErrorRatios, + getValues, + sumField, +} from '../../utils'; import { BackstageTheme } from '@backstage/theme'; -const getErrorRatios = (buildCounts?: BuildCount[]) => { - if (!buildCounts?.length) { - return undefined; - } - - return buildCounts.map(counts => - counts.builds === 0 ? 0 : counts.errors / counts.builds, - ); -}; - -const getBuildCounts = (buildCounts?: BuildCount[]) => { - if (!buildCounts?.length) { - return undefined; - } - - return buildCounts.map(counts => counts.builds); -}; - -const getBuildDurationsP50 = (buildTimes?: BuildTime[]) => { - if (!buildTimes?.length) { - return undefined; - } - - return buildTimes.map(times => times.durationP50); -}; - -const getAverageDuration = ( - buildTimes: BuildTime[] | undefined, - accessor: (b: BuildTime) => number, -) => { - if (!buildTimes?.length) { - return undefined; - } - - return formatDuration( - buildTimes.reduce((sum, current) => sum + accessor(current), 0) / - buildTimes.length, - ); -}; - -const getTotalBuildDuration = (buildTimes?: BuildTime[]) => { - if (!buildTimes?.length) { - return undefined; - } - - return formatDuration( - buildTimes.reduce((sum, current) => sum + current.totalDuration, 0), - ); -}; - const useStyles = makeStyles({ spacingTop: { marginTop: 8, @@ -85,30 +42,31 @@ const useStyles = makeStyles({ }, }); -export const OverviewTrendsComponent = ({ days }: { days: number }) => { +const DAYS_SELECT_ITEMS = [ + { label: '7 days', value: 7 }, + { label: '14 days', value: 14 }, + { label: '30 days', value: 30 }, + { label: '60 days', value: 60 }, +]; + +export const OverviewTrendsComponent = () => { + const [days, setDays] = useState(14); const theme = useTheme(); const classes = useStyles(); const client = useApi(xcmetricsApiRef); - const buildCountsResult = useAsync( - async () => client.getBuildCounts(days), - [], - ); - const buildTimesResult = useAsync(async () => client.getBuildTimes(days), []); + const buildCountsResult = useAsync(async () => client.getBuildCounts(days), [ + days, + ]); + const buildTimesResult = useAsync(async () => client.getBuildTimes(days), [ + days, + ]); if (buildCountsResult.loading && buildTimesResult.loading) { return ; } - const sumBuilds = buildCountsResult.value?.reduce( - (sum, current) => sum + current.builds, - 0, - ); - - const sumErrors = buildCountsResult.value?.reduce( - (sum, current) => sum + current.errors, - 0, - ); - + const sumBuilds = sumField(b => b.builds, buildCountsResult.value); + const sumErrors = sumField(b => b.errors, buildCountsResult.value); const errorRate = sumBuilds && sumErrors ? sumErrors / sumBuilds : undefined; const averageBuildDurationP50 = getAverageDuration( @@ -119,11 +77,16 @@ export const OverviewTrendsComponent = ({ days }: { days: number }) => { buildTimesResult.value, b => b.durationP95, ); - const totalBuildTime = getTotalBuildDuration(buildTimesResult.value); + const totalBuildTime = sumField(t => t.totalDuration, buildTimesResult.value); return ( <> - Last {days} Days +