diff --git a/.changeset/great-cherries-fly.md b/.changeset/great-cherries-fly.md new file mode 100644 index 0000000000..3480acd7be --- /dev/null +++ b/.changeset/great-cherries-fly.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-xcmetrics': patch +--- + +New page for browsing all builds with filtering and pagination capabilities diff --git a/plugins/xcmetrics/docs/XCMetrics-overview.png b/plugins/xcmetrics/docs/XCMetrics-overview.png index b778461120..6b95de3aa1 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 0d4ed0dee1..81b3e78a31 100644 --- a/plugins/xcmetrics/src/api/XcmetricsClient.ts +++ b/plugins/xcmetrics/src/api/XcmetricsClient.ts @@ -16,9 +16,11 @@ import { DiscoveryApi } from '@backstage/core-plugin-api'; import { ResponseError } from '@backstage/errors'; +import { DateTime } from 'luxon'; import { Build, BuildCount, + BuildFilters, BuildStatusResult, BuildTime, PaginationResult, @@ -58,6 +60,37 @@ export class XcmetricsClient implements XcmetricsApi { return ((await response.json()) as PaginationResult).items; } + async getFilteredBuilds( + filters: BuildFilters, + page?: number, + perPage?: number, + ): Promise> { + const baseUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/xcmetrics`; + const response = await fetch(`${baseUrl}/build/filter`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + from: DateTime.fromISO(filters.from) + .startOf('day') + .toISO({ suppressMilliseconds: true }), + to: DateTime.fromISO(filters.to) + .endOf('day') + .startOf('second') + .toISO({ suppressMilliseconds: true }), + status: filters.buildStatus, + projectName: filters.project, + page, + per: perPage, + }), + }); + + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + + return (await response.json()) as PaginationResult; + } + async getBuildCounts(days: number): Promise { const baseUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/xcmetrics`; const response = await fetch( @@ -97,4 +130,15 @@ export class XcmetricsClient implements XcmetricsApi { return ((await response.json()) as PaginationResult) .items; } + + async getProjects(): Promise { + const baseUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/xcmetrics`; + const response = await fetch(`${baseUrl}/build/project`); + + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + + return (await response.json()) as string[]; + } } diff --git a/plugins/xcmetrics/src/api/__mocks__/XcmetricsClient.ts b/plugins/xcmetrics/src/api/__mocks__/XcmetricsClient.ts index 5d53f41860..430fbb1006 100644 --- a/plugins/xcmetrics/src/api/__mocks__/XcmetricsClient.ts +++ b/plugins/xcmetrics/src/api/__mocks__/XcmetricsClient.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Build, XcmetricsApi } from '../types'; +import { Build, BuildFilters, XcmetricsApi } from '../types'; export const mockBuild = { userid: 'userid1', @@ -28,7 +28,7 @@ export const mockBuild = { day: '2021-01-01', compilationEndTimestamp: '2021-01-01T00:00:01Z', tag: '', - projectName: 'Project', + projectName: 'ProjectName', compilationEndTimestampMicroseconds: 1, errorCount: 1, id: 'buildId', @@ -61,6 +61,23 @@ export const XcmetricsClient: XcmetricsApi = { { ...mockBuild, id: '2', userid: 'userid2' }, ]); }, + getFilteredBuilds: ( + _filters: BuildFilters, + _page?: number, + _perPage?: number, + ) => { + return Promise.resolve({ + items: [ + mockBuild, + { ...mockBuild, buildStatus: 'failed', projectName: 'ProjectName2' }, + ], + metadata: { + per: 10, + total: 2, + page: 1, + }, + }); + }, getBuildCounts: () => { return Promise.resolve([mockBuildCount, mockBuildCount]); }, @@ -70,4 +87,7 @@ export const XcmetricsClient: XcmetricsApi = { getBuildTimes: (days: number) => { return Promise.resolve([mockBuildTime, mockBuildTime].slice(0, days)); }, + getProjects: () => { + return Promise.resolve([mockBuild.projectName]); + }, }; diff --git a/plugins/xcmetrics/src/api/types.ts b/plugins/xcmetrics/src/api/types.ts index 48bee5fd38..c018a67056 100644 --- a/plugins/xcmetrics/src/api/types.ts +++ b/plugins/xcmetrics/src/api/types.ts @@ -68,12 +68,25 @@ export type PaginationResult = { }; }; +export type BuildFilters = { + from: string; // ISO Date (e.g. "2021-01-01") + to: string; // ISO Date (e.g. "2021-01-02") + buildStatus?: BuildStatus; + project?: string; +}; + export interface XcmetricsApi { getBuild(id: string): Promise; - getBuilds(): Promise; + getBuilds(limit?: number): Promise; + getFilteredBuilds( + filters: BuildFilters, + page?: number, + perPage?: number, + ): Promise>; getBuildCounts(days: number): Promise; getBuildTimes(days: number): Promise; getBuildStatuses(limit: number): Promise; + getProjects(): Promise; } export const xcmetricsApiRef = createApiRef({ diff --git a/plugins/xcmetrics/src/components/BuildListComponent/BuildListComponent.test.tsx b/plugins/xcmetrics/src/components/BuildListComponent/BuildListComponent.test.tsx new file mode 100644 index 0000000000..3818d51a16 --- /dev/null +++ b/plugins/xcmetrics/src/components/BuildListComponent/BuildListComponent.test.tsx @@ -0,0 +1,61 @@ +/* + * 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 { BuildListComponent } from './BuildListComponent'; +import { xcmetricsApiRef } from '../../api'; + +jest.mock('../../api/XcmetricsClient'); +const client = require('../../api/XcmetricsClient'); + +jest.mock('../BuildListFilterComponent', () => ({ + BuildListFilterComponent: () => 'BuildListFilterComponent', +})); + +describe('BuildListComponent', () => { + it('should render', async () => { + const rendered = await renderInTestApp( + + + , + ); + + expect(rendered.getByText('Builds')).toBeInTheDocument(); + expect( + rendered.getByText(client.mockBuild.projectName), + ).toBeInTheDocument(); + }); + + it('should show errors', async () => { + const message = 'error'; + client.XcmetricsClient.getFilteredBuilds = jest + .fn() + .mockRejectedValue({ message }); + + const rendered = await renderInTestApp( + + + , + ); + + expect(rendered.getByText(message)).toBeInTheDocument(); + }); +}); diff --git a/plugins/xcmetrics/src/components/BuildListComponent/BuildListComponent.tsx b/plugins/xcmetrics/src/components/BuildListComponent/BuildListComponent.tsx new file mode 100644 index 0000000000..c9ab028359 --- /dev/null +++ b/plugins/xcmetrics/src/components/BuildListComponent/BuildListComponent.tsx @@ -0,0 +1,74 @@ +/* + * 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, { useRef, useState } from 'react'; +import { Table } from '@backstage/core-components'; +import { useApi } from '@backstage/core-plugin-api'; +import { BuildFilters, xcmetricsApiRef } from '../../api'; +import { Grid } from '@material-ui/core'; +import { BuildListFilterComponent as Filters } from '../BuildListFilterComponent'; +import { DateTime } from 'luxon'; +import { buildPageColumns } from '../BuildTableColumns'; + +export const BuildListComponent = () => { + const client = useApi(xcmetricsApiRef); + const tableRef = useRef(); + + const initialFilters = { + from: DateTime.now().minus({ year: 1 }).toISODate(), + to: DateTime.now().toISODate(), + }; + + const [filters, setFilters] = useState(initialFilters); + + const handleFilterChange = (values: BuildFilters) => { + setFilters(values); + tableRef.current?.onQueryChange(); + }; + + return ( + + + { + return new Promise((resolve, reject) => { + if (!query) return; + client + .getFilteredBuilds( + filters, + query.page + 1, // Page is 0-indexed in Table + query.pageSize, + ) + .then(result => { + resolve({ + data: result.items, + page: result.metadata.page - 1, + totalCount: result.metadata.total, + }); + }) + .catch(reason => reject(reason)); + }); + }} + /> + + ); +}; diff --git a/plugins/xcmetrics/src/components/BuildListComponent/index.ts b/plugins/xcmetrics/src/components/BuildListComponent/index.ts new file mode 100644 index 0000000000..3fb881458d --- /dev/null +++ b/plugins/xcmetrics/src/components/BuildListComponent/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 './BuildListComponent'; diff --git a/plugins/xcmetrics/src/components/BuildListFilterComponent/BuildListFilterComponent.test.tsx b/plugins/xcmetrics/src/components/BuildListFilterComponent/BuildListFilterComponent.test.tsx new file mode 100644 index 0000000000..eac5775d77 --- /dev/null +++ b/plugins/xcmetrics/src/components/BuildListFilterComponent/BuildListFilterComponent.test.tsx @@ -0,0 +1,158 @@ +/* + * 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 { BuildListFilterComponent } from './BuildListFilterComponent'; +import { BuildFilters, xcmetricsApiRef } from '../../api'; +import { RenderResult } from '@testing-library/react'; + +jest.mock('../../api/XcmetricsClient'); +const client = require('../../api/XcmetricsClient'); + +jest.mock('../DatePickerComponent', () => ({ + DatePickerComponent: () => 'DatePickerComponent', +})); + +const initialValues = { + from: '2020-07-30', + to: '2021-07-30', +}; + +const renderWithFiltersVisible = async ( + callback?: (filters: BuildFilters) => void, +) => { + const rendered = await renderInTestApp( + + + , + ); + + userEvent.click(rendered.getByLabelText('show filters')); + return rendered; +}; + +const setStatusFilter = async (rendered: RenderResult, option: string) => { + const statusSelect = rendered.getAllByTestId('select')[0]; + userEvent.click(statusSelect); + userEvent.click((await rendered.findAllByText(option))[0]); +}; + +const setProjectFilter = async (rendered: RenderResult, option: string) => { + const statusSelect = rendered.getAllByTestId('select')[1]; + userEvent.click(statusSelect); + const options = await rendered.findAllByText(option); + userEvent.click(options[options.length - 1]); +}; + +describe('BuildListFilterComponent', () => { + it('should render', async () => { + const rendered = await renderInTestApp( + + + , + ); + + expect(rendered.getByText('Filters (0)')).toBeInTheDocument(); + }); + + it('should toggle between showing and hiding filters', async () => { + const rendered = await renderWithFiltersVisible(); + + expect( + (await rendered.findAllByText('DatePickerComponent')).length, + ).toEqual(2); + expect(await rendered.findByText('Status')).toBeInTheDocument(); + expect(await rendered.findByText('Project')).toBeInTheDocument(); + + userEvent.click(rendered.getByLabelText('hide filters')); + expect(rendered.queryByText('DatePickerComponent')).toBeNull(); + expect(rendered.queryByText('Status')).toBeNull(); + expect(rendered.queryByText('Project')).toBeNull(); + }); + + it('should load projects', async () => { + const callback = jest.fn(); + const rendered = await renderWithFiltersVisible(callback); + userEvent.click((await rendered.findAllByText('All'))[1]); + + expect( + await rendered.findByText(client.mockBuild.projectName), + ).toBeInTheDocument(); + }); + + it('should call back with a status when status is selected', async () => { + const callback = jest.fn(); + const rendered = await renderWithFiltersVisible(callback); + + await setStatusFilter(rendered, 'Succeeded'); + expect(callback).toBeCalledWith({ + ...initialValues, + buildStatus: 'succeeded', + }); + + await setStatusFilter(rendered, 'All'); + expect(callback).toBeCalledWith(initialValues); + }); + + it('should call back with a project when project is selected', async () => { + const callback = jest.fn(); + const rendered = await renderWithFiltersVisible(callback); + + await setProjectFilter(rendered, client.mockBuild.projectName); + expect(callback).toBeCalledWith({ + ...initialValues, + project: client.mockBuild.projectName, + }); + + await setProjectFilter(rendered, 'All'); + expect(callback).toBeCalledWith(initialValues); + }); + + it('should display a count of active (changed) filters', async () => { + const rendered = await renderWithFiltersVisible(); + + await setStatusFilter(rendered, 'Failed'); + await setProjectFilter(rendered, client.mockBuild.projectName); + + expect(await rendered.findByText('Filters (2)')).toBeInTheDocument(); + }); + + it('should clear all filters', async () => { + const callback = jest.fn(); + const rendered = await renderWithFiltersVisible(callback); + + await setStatusFilter(rendered, 'Failed'); + await setProjectFilter(rendered, client.mockBuild.projectName); + + callback.mockClear(); + userEvent.click(await rendered.findByText('Clear all')); + + expect(callback).toHaveBeenCalledWith(initialValues); + expect(await rendered.findByText('Filters (0)')).toBeInTheDocument(); + }); +}); diff --git a/plugins/xcmetrics/src/components/BuildListFilterComponent/BuildListFilterComponent.tsx b/plugins/xcmetrics/src/components/BuildListFilterComponent/BuildListFilterComponent.tsx new file mode 100644 index 0000000000..ff45e15925 --- /dev/null +++ b/plugins/xcmetrics/src/components/BuildListFilterComponent/BuildListFilterComponent.tsx @@ -0,0 +1,158 @@ +/* + * 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, { useEffect, useState } from 'react'; +import { makeStyles, IconButton, Grid, Button } from '@material-ui/core'; +import FilterList from '@material-ui/icons/FilterList'; +import { InfoCard, Select } from '@backstage/core-components'; +import { BackstageTheme } from '@backstage/theme'; +import { useApi } from '@backstage/core-plugin-api'; +import { useAsync } from 'react-use'; +import { BuildFilters, BuildStatus, xcmetricsApiRef } from '../../api'; +import { DatePickerComponent } from '../DatePickerComponent'; + +const toSelectItems = (strings: string[]) => { + return strings.map(str => ({ label: str, value: str })); +}; + +const useStyles = makeStyles(theme => ({ + filtersContent: { + padding: theme.spacing(2, 2, 2, 2.5), + }, +})); + +type FilterOption = T | 'all'; + +interface FiltersProps { + initialValues: BuildFilters; + onFilterChange: (filters: BuildFilters) => void; +} + +export const BuildListFilterComponent = ({ + onFilterChange, + initialValues, +}: FiltersProps) => { + const client = useApi(xcmetricsApiRef); + const classes = useStyles(); + const [open, setOpen] = useState(false); + const [values, setValues] = useState(initialValues); + + useEffect(() => onFilterChange(values), [onFilterChange, values]); + + const numFilters = Object.keys(values).reduce((sum, key) => { + const filtersKey = key as keyof BuildFilters; + return sum + Number(values[filtersKey] !== initialValues[filtersKey]); + }, 0); + + const title = ( + <> + setOpen(!open)} + aria-label={`${open ? 'hide' : 'show'} filters`} + > + + + Filters ({numFilters}) + {!!numFilters && ( + + )} + + ); + + const statusItems: { label: string; value: FilterOption }[] = [ + { label: 'All', value: 'all' }, + { label: 'Succeeded', value: 'succeeded' }, + { label: 'Failed', value: 'failed' }, + { label: 'Stopped', value: 'stopped' }, + ]; + + const { value: projects, loading } = useAsync(async () => { + return client.getProjects(); + }, []); + + const content = ( + + + setValues({ ...values, from: date })} + /> + + + setValues({ ...values, to: date })} + /> + + + undefined} + /> + ) : ( +
Latest Builds diff --git a/plugins/xcmetrics/src/components/XcmetricsLayout/XcmetricsLayout.test.tsx b/plugins/xcmetrics/src/components/XcmetricsLayout/XcmetricsLayout.test.tsx new file mode 100644 index 0000000000..2ce634dc11 --- /dev/null +++ b/plugins/xcmetrics/src/components/XcmetricsLayout/XcmetricsLayout.test.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 { renderInTestApp } from '@backstage/test-utils'; +import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { XcmetricsLayout } from './XcmetricsLayout'; +import { xcmetricsApiRef } from '../../api'; + +jest.mock('../../api/XcmetricsClient'); +const client = require('../../api/XcmetricsClient'); + +jest.mock('../OverviewComponent', () => ({ + OverviewComponent: () => 'OverviewComponent', +})); + +jest.mock('../BuildListComponent', () => ({ + BuildListComponent: () => 'BuildListComponent', +})); + +describe('XcmetricsLayout', () => { + it('should render', async () => { + const rendered = await renderInTestApp( + + + , + ); + + expect(rendered.getByText('Overview')).toBeInTheDocument(); + expect(rendered.getByText('Builds')).toBeInTheDocument(); + + expect(rendered.getByText('OverviewComponent')).toBeInTheDocument(); + }); +}); diff --git a/plugins/xcmetrics/src/components/XcmetricsLayout/XcmetricsLayout.tsx b/plugins/xcmetrics/src/components/XcmetricsLayout/XcmetricsLayout.tsx index 96d841b083..48faaaa3a6 100644 --- a/plugins/xcmetrics/src/components/XcmetricsLayout/XcmetricsLayout.tsx +++ b/plugins/xcmetrics/src/components/XcmetricsLayout/XcmetricsLayout.tsx @@ -13,9 +13,37 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; -import { Content, Header, HeaderLabel, Page } from '@backstage/core-components'; +import React, { ReactChild } from 'react'; +import { + Content, + Header, + HeaderLabel, + Page, + TabbedLayout, +} from '@backstage/core-components'; import { OverviewComponent } from '../OverviewComponent'; +import { buildsRouteRef, rootRouteRef } from '../../routes'; +import { RouteRef, SubRouteRef } from '@backstage/core-plugin-api'; +import { BuildListComponent } from '../BuildListComponent'; + +export interface TabConfig { + routeRef: RouteRef | SubRouteRef; + title: string; + component: ReactChild; +} + +const TABS: TabConfig[] = [ + { + routeRef: rootRouteRef, + title: 'Overview', + component: , + }, + { + routeRef: buildsRouteRef, + title: 'Builds', + component: , + }, +]; export const XcmetricsLayout = () => ( @@ -23,8 +51,16 @@ export const XcmetricsLayout = () => ( - - - + + {TABS.map(tab => ( + + {tab.component} + + ))} + ); diff --git a/plugins/xcmetrics/src/plugin.test.ts b/plugins/xcmetrics/src/plugin.test.ts index 055c647dd9..a390e8aeb7 100644 --- a/plugins/xcmetrics/src/plugin.test.ts +++ b/plugins/xcmetrics/src/plugin.test.ts @@ -13,10 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { xcmetricsPlugin } from './plugin'; +import { XcmetricsPage, xcmetricsPlugin } from './plugin'; describe('xcmetrics', () => { it('should export plugin', () => { expect(xcmetricsPlugin).toBeDefined(); + expect(XcmetricsPage).toBeDefined(); }); }); diff --git a/plugins/xcmetrics/src/routes.ts b/plugins/xcmetrics/src/routes.ts index 7acd62c363..212ecbae48 100644 --- a/plugins/xcmetrics/src/routes.ts +++ b/plugins/xcmetrics/src/routes.ts @@ -13,8 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createRouteRef } from '@backstage/core-plugin-api'; +import { createRouteRef, createSubRouteRef } from '@backstage/core-plugin-api'; export const rootRouteRef = createRouteRef({ title: 'XCMetrics', }); + +export const buildsRouteRef = createSubRouteRef({ + id: 'xcmetrics-builds', + parent: rootRouteRef, + path: '/builds', +}); diff --git a/plugins/xcmetrics/src/utils/format.ts b/plugins/xcmetrics/src/utils/format.ts index 085441bb84..8c6dadb768 100644 --- a/plugins/xcmetrics/src/utils/format.ts +++ b/plugins/xcmetrics/src/utils/format.ts @@ -23,8 +23,7 @@ export const formatDuration = (seconds: number) => { const h = duration.hours ? `${duration.hours} h` : ''; const m = duration.minutes ? `${duration.minutes} m` : ''; - const s = - duration.hours < 12 && duration.seconds ? `${duration.seconds} s` : ''; + const s = duration.hours < 12 ? `${duration.seconds ?? 0} s` : ''; return `${h} ${m} ${s}`; };