diff --git a/.changeset/weak-toes-agree.md b/.changeset/weak-toes-agree.md new file mode 100644 index 0000000000..8bdcf46fa2 --- /dev/null +++ b/.changeset/weak-toes-agree.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-xcmetrics': minor +--- + +New data in form of trend lines, status timeline and other is added to the dashboard of XCMetrics to give a better understanding of how the build system is behaving. diff --git a/plugins/xcmetrics/docs/XCMetrics-overview.png b/plugins/xcmetrics/docs/XCMetrics-overview.png index 69b77f87df..3ee4b1915a 100644 Binary files a/plugins/xcmetrics/docs/XCMetrics-overview.png and b/plugins/xcmetrics/docs/XCMetrics-overview.png differ diff --git a/plugins/xcmetrics/src/api/XcmetricsClient.ts b/plugins/xcmetrics/src/api/XcmetricsClient.ts index 00087ad75d..0d4ed0dee1 100644 --- a/plugins/xcmetrics/src/api/XcmetricsClient.ts +++ b/plugins/xcmetrics/src/api/XcmetricsClient.ts @@ -16,7 +16,14 @@ import { DiscoveryApi } from '@backstage/core-plugin-api'; import { ResponseError } from '@backstage/errors'; -import { BuildItem, BuildsResult, XcmetricsApi } from './types'; +import { + Build, + BuildCount, + BuildStatusResult, + BuildTime, + PaginationResult, + XcmetricsApi, +} from './types'; interface Options { discoveryApi: DiscoveryApi; @@ -29,14 +36,65 @@ export class XcmetricsClient implements XcmetricsApi { this.discoveryApi = options.discoveryApi; } - async getBuilds(): Promise { + async getBuild(id: string): Promise { const baseUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/xcmetrics`; - const response = await fetch(`${baseUrl}/build`); + const response = await fetch(`${baseUrl}/build/${id}`); if (!response.ok) { throw await ResponseError.fromResponse(response); } - return ((await response.json()) as BuildsResult).items; + 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}`); + + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + + return ((await response.json()) as PaginationResult).items; + } + + async getBuildCounts(days: number): Promise { + const baseUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/xcmetrics`; + const response = await fetch( + `${baseUrl}/statistics/build/count?days=${days}`, + ); + + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + + 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( + `${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/__mocks__/XcmetricsClient.ts b/plugins/xcmetrics/src/api/__mocks__/XcmetricsClient.ts new file mode 100644 index 0000000000..5d53f41860 --- /dev/null +++ b/plugins/xcmetrics/src/api/__mocks__/XcmetricsClient.ts @@ -0,0 +1,73 @@ +/* + * 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 { Build, XcmetricsApi } from '../types'; + +export const mockBuild = { + userid: 'userid1', + warningCount: 1, + duration: 1, + startTimestamp: '2021-01-01T00:00:00Z', + isCi: true, + startTimestampMicroseconds: 0, + category: '', + endTimestampMicroseconds: 10000, + day: '2021-01-01', + compilationEndTimestamp: '2021-01-01T00:00:01Z', + tag: '', + projectName: 'Project', + compilationEndTimestampMicroseconds: 1, + errorCount: 1, + id: 'buildId', + buildStatus: 'succeeded', + compilationDuration: 1, + schema: 'Schema', + compiledCount: 1, + endTimestamp: '2021-01-01T00:00:01Z', + userid256: 'userId256', + machineName: 'Example_Machine', + wasSuspended: true, +} as Build; + +export const mockBuildCount = { day: '2021-07-10', builds: 10, errors: 1 }; +export const mockBuildTime = { + day: '2021-07-10', + durationP50: 1.1, + durationP95: 2.1, + totalDuration: 3.1, +}; +export const mockBuildStatus = { id: 'build_id', status: 'succeeded' }; + +export const XcmetricsClient: XcmetricsApi = { + getBuild: (id: string) => { + return Promise.resolve({ ...mockBuild, id }); + }, + getBuilds: () => { + return Promise.resolve([ + { ...mockBuild, id: '1' }, + { ...mockBuild, id: '2', userid: 'userid2' }, + ]); + }, + getBuildCounts: () => { + return Promise.resolve([mockBuildCount, mockBuildCount]); + }, + getBuildStatuses: (limit: number) => { + return Promise.resolve([mockBuild].slice(0, limit)); + }, + getBuildTimes: (days: number) => { + return Promise.resolve([mockBuildTime, mockBuildTime].slice(0, days)); + }, +}; diff --git a/plugins/xcmetrics/src/api/types.ts b/plugins/xcmetrics/src/api/types.ts index add59b1a86..48bee5fd38 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,23 @@ export type BuildItem = { wasSuspended: boolean; }; -export type BuildsResult = { - items: BuildItem[]; +export type BuildStatusResult = Pick; + +export type BuildCount = { + day: string; + errors: number; + builds: number; +}; + +export type BuildTime = { + day: string; + durationP50: number; + durationP95: number; + totalDuration: number; +}; + +export type PaginationResult = { + items: T[]; metadata: { per: number; total: number; @@ -54,7 +69,11 @@ export type BuildsResult = { }; export interface XcmetricsApi { - getBuilds(): Promise; + getBuild(id: string): Promise; + getBuilds(): Promise; + getBuildCounts(days: number): Promise; + getBuildTimes(days: number): Promise; + getBuildStatuses(limit: number): Promise; } export const xcmetricsApiRef = createApiRef({ 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..df52d020ee --- /dev/null +++ b/plugins/xcmetrics/src/components/DataValueComponent/DataValueComponent.test.tsx @@ -0,0 +1,49 @@ +/* + * 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('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('--')).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..35b0331001 --- /dev/null +++ b/plugins/xcmetrics/src/components/DataValueComponent/DataValueComponent.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 { Grid, GridSize, Typography } from '@material-ui/core'; +import React from 'react'; + +interface DataValueProps { + field: string; + value?: string | number | null | undefined; +} + +export const DataValueComponent = ({ field, value }: DataValueProps) => { + return ( +
+ {field} + {value ?? '--'} +
+ ); +}; + +interface GridProps { + xs?: GridSize; + md?: GridSize; + lg?: 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/OverviewComponent/OverviewComponent.test.tsx b/plugins/xcmetrics/src/components/OverviewComponent/OverviewComponent.test.tsx index d386d279ce..fb5ef42cd8 100644 --- a/plugins/xcmetrics/src/components/OverviewComponent/OverviewComponent.test.tsx +++ b/plugins/xcmetrics/src/components/OverviewComponent/OverviewComponent.test.tsx @@ -14,62 +14,61 @@ * limitations under the License. */ 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 { OverviewComponent } from './OverviewComponent'; + +jest.mock('../../api/XcmetricsClient'); +const client = require('../../api/XcmetricsClient'); + +jest.mock('../OverviewTrendsComponent', () => ({ + OverviewTrendsComponent: () => 'OverviewTrendsComponent', +})); + +jest.mock('../StatusMatrixComponent', () => ({ + StatusMatrixComponent: () => 'StatusMatrixComponent', +})); 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( - + , ); + expect(rendered.getByText('XCMetrics Dashboard')).toBeInTheDocument(); - expect(rendered.getByText(mockUserId)).toBeInTheDocument(); - expect(rendered.queryByText('CI')).toBeNull(); + expect(rendered.getByText(client.mockBuild.userid)).toBeInTheDocument(); }); it('should render an empty state when no builds exist', async () => { - const mockApi: jest.Mocked = { - getBuilds: jest.fn().mockResolvedValue([]), - }; + const api = client.XcmetricsClient; + 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 = client.XcmetricsClient; const errorMessage = 'MockErrorMessage'; - const mockApi: jest.Mocked = { - 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 37b35c9690..cee8e2a549 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, @@ -24,31 +24,28 @@ import { Table, TableColumn, EmptyState, + InfoCard, } 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 { Duration } from 'luxon'; -import { Chip } from '@material-ui/core'; +import { StatusMatrixComponent } from '../StatusMatrixComponent'; +import { formatDuration, formatTime } from '../../utils'; +import { Chip, Grid } from '@material-ui/core'; +import { OverviewTrendsComponent } from '../OverviewTrendsComponent'; -const formatStatus = (status: BuildStatus, warningCount: number) => { - const statusIcons = { - succeeded: , - failed: , - stopped: , - }; - - return ( - <> - {statusIcons[status]} {status[0].toUpperCase() + status.slice(1)} - {warningCount > 0 && ` with ${warningCount} warning`} - {warningCount > 1 && 's'} - - ); +const STATUS_ICONS: { [key in BuildStatus]: ReactChild } = { + succeeded: , + failed: , + stopped: , }; -const columns: TableColumn[] = [ +const columns: TableColumn[] = [ + { + field: 'buildStatus', + render: data => STATUS_ICONS[data.buildStatus], + }, { title: 'Project', field: 'projectName', @@ -57,22 +54,21 @@ const columns: TableColumn[] = [ title: 'Schema', field: 'schema', }, + { + title: 'Started', + field: 'startedAt', + searchable: false, + render: data => formatTime(data.startTimestamp), + }, { title: 'Duration', field: 'duration', - type: 'time', - searchable: false, - render: data => Duration.fromObject({ seconds: data.duration }).toISOTime(), + render: data => formatDuration(data.duration), }, { title: 'User', field: 'userid', }, - { - title: 'Status', - field: 'buildStatus', - render: data => formatStatus(data.buildStatus, data.warningCount), - }, { field: 'isCI', render: data => data.isCi && , @@ -84,7 +80,7 @@ const columns: TableColumn[] = [ export const OverviewComponent = () => { const client = useApi(xcmetricsApiRef); const { value: builds, loading, error } = useAsync( - async (): Promise => client.getBuilds(), + async () => client.getBuilds(), [], ); @@ -109,12 +105,26 @@ export const OverviewComponent = () => { Dashboard for XCMetrics - + + +
+ Latest Builds + + + } + /> + + + + + + + ); }; 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..cb33be1451 --- /dev/null +++ b/plugins/xcmetrics/src/components/OverviewTrendsComponent/OverviewTrendsComponent.test.tsx @@ -0,0 +1,86 @@ +/* + * 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 userEvent from '@testing-library/user-event'; + +jest.mock('../../api/XcmetricsClient'); +const client = require('../../api/XcmetricsClient'); + +describe('OverviewTrendsComponent', () => { + it('should render', async () => { + const rendered = await renderInTestApp( + + + , + ); + expect(rendered.getByText('Trends for')).toBeInTheDocument(); + expect(rendered.getAllByText('Build Count').length).toEqual(3); + expect(rendered.getByText('Avg. Build Time (P50)')).toBeInTheDocument(); + }); + + it('should render empty state', async () => { + const api = client.XcmetricsClient; + api.getBuildCounts = jest.fn().mockResolvedValue([]); + + 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 = client.XcmetricsClient; + const buildCountError = 'MockBuildCountErrorMessage'; + const buildTimesError = 'MockBuildTimesErrorMessage'; + + api.getBuildCounts = jest + .fn() + .mockRejectedValue({ message: buildCountError }); + api.getBuildTimes = jest + .fn() + .mockRejectedValue({ message: buildTimesError }); + + const rendered = await renderInTestApp( + + + , + ); + 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 new file mode 100644 index 0000000000..2c4a04d582 --- /dev/null +++ b/plugins/xcmetrics/src/components/OverviewTrendsComponent/OverviewTrendsComponent.tsx @@ -0,0 +1,148 @@ +/* + * 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, 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 { xcmetricsApiRef } from '../../api'; +import { useAsync } from 'react-use'; +import { useApi } from '@backstage/core-plugin-api'; +import { DataValueGridItem } from '../DataValueComponent'; +import { + formatDuration, + formatPercentage, + getAverageDuration, + getErrorRatios, + getValues, + sumField, +} from '../../utils'; +import { BackstageTheme } from '@backstage/theme'; + +const useStyles = makeStyles({ + spacingTop: { + marginTop: 8, + }, + spacingVertical: { + marginTop: 8, + marginBottom: 8, + }, +}); + +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), [ + days, + ]); + const buildTimesResult = useAsync(async () => client.getBuildTimes(days), [ + days, + ]); + + if (buildCountsResult.loading && buildTimesResult.loading) { + return ; + } + + 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( + buildTimesResult.value, + b => b.durationP50, + ); + const averageBuildDurationP95 = getAverageDuration( + buildTimesResult.value, + b => b.durationP95, + ); + const totalBuildTime = sumField(t => t.totalDuration, buildTimesResult.value); + + return ( + <> +
+ + + + + + + + + + + + + + +
Started{new Date(build.startTimestamp).toLocaleString()}
Duration{formatDuration(build.duration)}
Status{formatStatus(build.buildStatus)}
+ ); +}; + +interface StatusCellProps { + buildStatus?: BuildStatusResult; + size: number; + spacing: number; +} + +type StatusStyle = { + [key in BuildStatus]: any; +}; + +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, + }, + } 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 } = props; + + if (!buildStatus) { + return
; + } + + 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 new file mode 100644 index 0000000000..aac029a919 --- /dev/null +++ b/plugins/xcmetrics/src/components/StatusMatrixComponent/StatusMatrixComponent.test.tsx @@ -0,0 +1,38 @@ +/* + * 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 { StatusMatrixComponent } from './StatusMatrixComponent'; +import { xcmetricsApiRef } from '../../api'; + +jest.mock('../../api/XcmetricsClient'); +const client = require('../../api/XcmetricsClient'); + +describe('StatusMatrixComponent', () => { + it('should render', async () => { + const rendered = await renderInTestApp( + + + , + ); + + const cell = rendered.getByTestId(client.mockBuild.id); + expect(cell).toBeInTheDocument(); + }); +}); diff --git a/plugins/xcmetrics/src/components/StatusMatrixComponent/StatusMatrixComponent.tsx b/plugins/xcmetrics/src/components/StatusMatrixComponent/StatusMatrixComponent.tsx new file mode 100644 index 0000000000..3a6c7e1618 --- /dev/null +++ b/plugins/xcmetrics/src/components/StatusMatrixComponent/StatusMatrixComponent.tsx @@ -0,0 +1,91 @@ +/* + * 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 { makeStyles } from '@material-ui/core'; +import { BackstageTheme } from '@backstage/theme'; +import { xcmetricsApiRef } from '../../api'; +import { useAsync, useMeasure } from 'react-use'; +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; +const MAX_ROWS = 4; + +const useStyles = makeStyles(theme => ({ + root: { + marginTop: 8, + display: 'flex', + flexWrap: 'wrap', + width: '100%', + }, + loading: { + animation: `$loadingOpacity 900ms ${theme.transitions.easing.easeInOut}`, + animationIterationCount: 'infinite', + }, + '@keyframes loadingOpacity': { + '0%': { opacity: 0.3 }, + '100%': { opacity: 0.8 }, + }, +})); + +export const StatusMatrixComponent = () => { + const classes = useStyles(); + const [measureRef, { width: rootWidth }] = useMeasure(); + const client = useApi(xcmetricsApiRef); + const { value: builds, loading, error } = useAsync( + async () => client.getBuildStatuses(300), + [], + ); + + if (error) { + return {error.message}; + } + + const cols = Math.trunc(rootWidth / (CELL_SIZE + CELL_MARGIN)) || 1; + + return ( +
+ {loading && + [...new Array(cols * MAX_ROWS)].map((_, index) => { + return ( + + ); + })} + + {builds && + builds + .slice(0, cols * MAX_ROWS) + .map((buildStatus, index) => ( + + ))} +
+ ); +}; 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'; diff --git a/plugins/xcmetrics/src/components/TrendComponent/TrendComponent.test.tsx b/plugins/xcmetrics/src/components/TrendComponent/TrendComponent.test.tsx new file mode 100644 index 0000000000..0a1b0e0b32 --- /dev/null +++ b/plugins/xcmetrics/src/components/TrendComponent/TrendComponent.test.tsx @@ -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 React from 'react'; +import { TrendComponent } from './TrendComponent'; +import { renderInTestApp } from '@backstage/test-utils'; + +describe('TrendComponent', () => { + it('should render', async () => { + const data = [1, 2, 3, 4]; + const title = 'testTitle'; + const rendered = await renderInTestApp( + , + ); + 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/TrendComponent/TrendComponent.tsx b/plugins/xcmetrics/src/components/TrendComponent/TrendComponent.tsx new file mode 100644 index 0000000000..ece6acfd6c --- /dev/null +++ b/plugins/xcmetrics/src/components/TrendComponent/TrendComponent.tsx @@ -0,0 +1,41 @@ +/* + * 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 { Typography } from '@material-ui/core'; + +interface TrendProps { + data?: number[]; + title: string; + color: string; +} + +export const TrendComponent = ({ data, title, color }: TrendProps) => { + const emptyData = [0, 0]; + const max = Math.max(...(data ?? emptyData)); + + return ( + <> + {title} + + + ); +}; diff --git a/plugins/xcmetrics/src/components/TrendComponent/index.ts b/plugins/xcmetrics/src/components/TrendComponent/index.ts new file mode 100644 index 0000000000..69dce3ff7f --- /dev/null +++ b/plugins/xcmetrics/src/components/TrendComponent/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 './TrendComponent'; diff --git a/plugins/xcmetrics/src/utils/array.ts b/plugins/xcmetrics/src/utils/array.ts new file mode 100644 index 0000000000..9e3e2d1d4d --- /dev/null +++ b/plugins/xcmetrics/src/utils/array.ts @@ -0,0 +1,33 @@ +/* + * 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 const sumField = ( + field: (element: T) => number, + arr?: T[], +) => { + return arr?.reduce((sum, current) => sum + field(current), 0); +}; + +export const getValues = ( + field: (element: T) => number, + arr?: T[], +) => { + if (!arr?.length) { + return undefined; + } + + return arr.map(element => field(element)); +}; diff --git a/plugins/xcmetrics/src/utils/buildData.ts b/plugins/xcmetrics/src/utils/buildData.ts new file mode 100644 index 0000000000..df6fad996e --- /dev/null +++ b/plugins/xcmetrics/src/utils/buildData.ts @@ -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 { formatDuration } from '.'; +import { BuildCount, BuildTime } from '../api'; + +export const getErrorRatios = (buildCounts?: BuildCount[]) => { + if (!buildCounts?.length) { + return undefined; + } + + return buildCounts.map(counts => + counts.builds === 0 ? 0 : counts.errors / counts.builds, + ); +}; + +export const getAverageDuration = ( + buildTimes: BuildTime[] | undefined, + field: (b: BuildTime) => number, +) => { + if (!buildTimes?.length) { + return undefined; + } + + return formatDuration( + buildTimes.reduce((sum, current) => sum + field(current), 0) / + buildTimes.length, + ); +}; 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/format.ts b/plugins/xcmetrics/src/utils/format.ts new file mode 100644 index 0000000000..085441bb84 --- /dev/null +++ b/plugins/xcmetrics/src/utils/format.ts @@ -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 { DateTime, Duration } from 'luxon'; +import { BuildStatus } from '../api'; + +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( + 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); diff --git a/plugins/xcmetrics/src/utils/index.ts b/plugins/xcmetrics/src/utils/index.ts new file mode 100644 index 0000000000..f487041e25 --- /dev/null +++ b/plugins/xcmetrics/src/utils/index.ts @@ -0,0 +1,19 @@ +/* + * 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 './array'; +export * from './buildData'; +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..a6a324e533 --- /dev/null +++ b/plugins/xcmetrics/src/utils/utils.test.ts @@ -0,0 +1,88 @@ +/* + * 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, getValues, sumField } from './'; +import { getAverageDuration, getErrorRatios } from './buildData'; + +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', + ); + }); + }); + + describe('sumFields', () => { + it('should sum the given field', () => { + expect(sumField(e => e.a, [{ a: 1 }, { a: 1 }, { a: 1, b: 10 }])).toEqual( + 3, + ); + expect(sumField(e => (e as any).field)).toBeUndefined(); + }); + }); + + describe('getValues', () => { + it('should return the values of the specified field', () => { + expect(getValues(e => e.a, [{ a: 1 }, { a: 2, b: 10 }])).toEqual([1, 2]); + expect(getValues(e => (e as any).field)).toBeUndefined(); + expect(getValues(e => e.field, [] as { field: any }[])).toBeUndefined(); + }); + }); + + describe('getErrorRatios', () => { + it('should return the ratio between errors and builds', () => { + expect( + getErrorRatios([{ day: '2021-01-01', errors: 10, builds: 1 }]), + ).toEqual([10]); + expect( + getErrorRatios([{ day: '2021-01-01', errors: 0, builds: 0 }]), + ).toEqual([0]); + expect(getErrorRatios()).toBeUndefined(); + }); + }); + + describe('getAverageDuration', () => { + it('should return the average duration', () => { + const data = [ + { + day: '2021-01-01', + durationP50: 3.0, + durationP95: 0, + totalDuration: 0, + }, + { + day: '2021-01-01', + durationP50: 1.0, + durationP95: 0, + totalDuration: 0, + }, + ]; + expect(getAverageDuration(data, e => e.durationP50)).toMatch('2 s'); + expect(getAverageDuration([], e => e.durationP50)).toBeUndefined(); + }); + }); +});