Merge pull request #6671 from ngranander/xcmetrics-builds-page

Xcmetrics builds page
This commit is contained in:
Ben Lambert
2021-08-02 16:19:12 +02:00
committed by GitHub
21 changed files with 909 additions and 64 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-xcmetrics': patch
---
New page for browsing all builds with filtering and pagination capabilities
Binary file not shown.

Before

Width:  |  Height:  |  Size: 560 KiB

After

Width:  |  Height:  |  Size: 870 KiB

@@ -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<Build>).items;
}
async getFilteredBuilds(
filters: BuildFilters,
page?: number,
perPage?: number,
): Promise<PaginationResult<Build>> {
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<Build>;
}
async getBuildCounts(days: number): Promise<BuildCount[]> {
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<BuildStatusResult>)
.items;
}
async getProjects(): Promise<string[]> {
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[];
}
}
@@ -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]);
},
};
+14 -1
View File
@@ -68,12 +68,25 @@ export type PaginationResult<T> = {
};
};
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<Build>;
getBuilds(): Promise<Build[]>;
getBuilds(limit?: number): Promise<Build[]>;
getFilteredBuilds(
filters: BuildFilters,
page?: number,
perPage?: number,
): Promise<PaginationResult<Build>>;
getBuildCounts(days: number): Promise<BuildCount[]>;
getBuildTimes(days: number): Promise<BuildTime[]>;
getBuildStatuses(limit: number): Promise<BuildStatusResult[]>;
getProjects(): Promise<string[]>;
}
export const xcmetricsApiRef = createApiRef<XcmetricsApi>({
@@ -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(
<ApiProvider
apis={ApiRegistry.with(xcmetricsApiRef, client.XcmetricsClient)}
>
<BuildListComponent />
</ApiProvider>,
);
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(
<ApiProvider
apis={ApiRegistry.with(xcmetricsApiRef, client.XcmetricsClient)}
>
<BuildListComponent />
</ApiProvider>,
);
expect(rendered.getByText(message)).toBeInTheDocument();
});
});
@@ -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<any>();
const initialFilters = {
from: DateTime.now().minus({ year: 1 }).toISODate(),
to: DateTime.now().toISODate(),
};
const [filters, setFilters] = useState<BuildFilters>(initialFilters);
const handleFilterChange = (values: BuildFilters) => {
setFilters(values);
tableRef.current?.onQueryChange();
};
return (
<Grid container spacing={3} direction="column">
<Filters
onFilterChange={handleFilterChange}
initialValues={initialFilters}
/>
<Table
title="Builds"
columns={buildPageColumns}
options={{ paging: true, sorting: false, search: false, pageSize: 10 }}
tableRef={tableRef}
data={query => {
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));
});
}}
/>
</Grid>
);
};
@@ -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';
@@ -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(
<ApiProvider
apis={ApiRegistry.with(xcmetricsApiRef, client.XcmetricsClient)}
>
<BuildListFilterComponent
initialValues={initialValues}
onFilterChange={callback ?? jest.fn()}
/>
</ApiProvider>,
);
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(
<ApiProvider
apis={ApiRegistry.with(xcmetricsApiRef, client.XcmetricsClient)}
>
<BuildListFilterComponent
initialValues={initialValues}
onFilterChange={jest.fn()}
/>
</ApiProvider>,
);
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();
});
});
@@ -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<BackstageTheme>(theme => ({
filtersContent: {
padding: theme.spacing(2, 2, 2, 2.5),
},
}));
type FilterOption<T> = 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 = (
<>
<IconButton
onClick={() => setOpen(!open)}
aria-label={`${open ? 'hide' : 'show'} filters`}
>
<FilterList />
</IconButton>
Filters ({numFilters})
{!!numFilters && (
<Button color="primary" onClick={() => setValues(initialValues)}>
Clear all
</Button>
)}
</>
);
const statusItems: { label: string; value: FilterOption<BuildStatus> }[] = [
{ 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 = (
<Grid
container
spacing={3}
direction="row"
className={classes.filtersContent}
>
<Grid item sm={6} md={4} lg={2}>
<DatePickerComponent
label="From"
value={values.from}
onDateChange={date => setValues({ ...values, from: date })}
/>
</Grid>
<Grid item sm={6} md={4} lg={2}>
<DatePickerComponent
label="To"
value={values.to}
onDateChange={date => setValues({ ...values, to: date })}
/>
</Grid>
<Grid item sm={6} md={4} lg={2}>
<Select
label="Status"
items={statusItems}
selected={!values.buildStatus ? 'all' : values.buildStatus}
onChange={selection => {
const buildStatus =
selection === 'all' ? undefined : (selection as BuildStatus);
setValues({ ...values, buildStatus });
}}
/>
</Grid>
<Grid item sm={6} md={4} lg={2}>
{loading ? (
<Select
label="Project"
placeholder="Loading.."
items={[]}
onChange={() => undefined}
/>
) : (
<Select
label="Project"
items={toSelectItems(['All'].concat(projects ?? []))}
selected={values.project ? values.project : 'All'}
onChange={selection =>
setValues({
...values,
project:
selection === 'All' ? undefined : (selection as string),
})
}
/>
)}
</Grid>
</Grid>
);
return (
<InfoCard
title={title}
titleTypographyProps={{ variant: 'h6' }}
divider={open}
noPadding
variant="gridItem"
>
{open && content}
</InfoCard>
);
};
@@ -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 './BuildListFilterComponent';
@@ -0,0 +1,89 @@
/*
* 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 { Chip } from '@material-ui/core';
import React, { ReactChild } from 'react';
import {
StatusOK,
StatusError,
StatusWarning,
TableColumn,
} from '@backstage/core-components';
import { Build, BuildStatus } from '../api';
import { formatTime, formatDuration } from '../utils';
const STATUS_ICONS: { [key in BuildStatus]: ReactChild } = {
succeeded: <StatusOK />,
failed: <StatusError />,
stopped: <StatusWarning />,
};
const baseColumns: TableColumn<Build>[] = [
{
field: 'buildStatus',
render: data => STATUS_ICONS[data.buildStatus],
},
{
title: 'Project',
field: 'projectName',
},
{
title: 'Schema',
field: 'schema',
},
{
title: 'Started',
field: 'startedAt',
render: data => formatTime(data.startTimestamp),
cellStyle: { whiteSpace: 'nowrap' },
},
{
title: 'Duration',
field: 'duration',
render: data => formatDuration(data.duration),
},
{
title: 'User',
field: 'userid',
},
];
const isCi: TableColumn<Build> = {
field: 'isCI',
render: data => data.isCi && <Chip label="CI" size="small" />,
width: '10',
sorting: false,
};
export const overviewColumns: TableColumn<Build>[] = [...baseColumns, isCi];
export const buildPageColumns: TableColumn<Build>[] = [
...baseColumns,
{
title: 'Host',
field: 'machineName',
},
{
title: 'Warnings',
field: 'warningCount',
},
{
title: 'Category',
field: 'category',
render: data => <Chip label={data.category} size="small" />,
},
isCi,
];
@@ -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 { renderInTestApp } from '@backstage/test-utils';
import { DatePickerComponent } from './DatePickerComponent';
import userEvent from '@testing-library/user-event';
describe('DatePickerComponent', () => {
it('should render', async () => {
const label = 'label';
const rendered = await renderInTestApp(
<DatePickerComponent label={label} />,
);
expect(rendered.getByText(label)).toBeInTheDocument();
});
it('should accept a date', async () => {
const label = 'label';
const callback = jest.fn();
const rendered = await renderInTestApp(
<DatePickerComponent label={label} onDateChange={callback} />,
);
const input = rendered.getByLabelText(label);
userEvent.type(input, '2020-02-02');
expect(callback).toBeCalledWith('2020-02-02');
});
it('should not accept non date', async () => {
const label = 'label';
const callback = jest.fn();
const rendered = await renderInTestApp(
<DatePickerComponent label={label} onDateChange={callback} />,
);
const input = rendered.getByLabelText(label);
userEvent.type(input, 'test');
expect(callback).not.toHaveBeenCalled();
});
});
@@ -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 React from 'react';
import {
createStyles,
InputBase,
InputProps,
Theme,
Typography,
withStyles,
} from '@material-ui/core';
const BootstrapInput = withStyles((theme: Theme) =>
createStyles({
root: {
margin: `${theme.spacing(1)} 0px`,
maxWidth: 300,
'label + &': {
marginTop: theme.spacing(3),
},
},
input: {
borderRadius: 4,
position: 'relative',
backgroundColor: theme.palette.background.paper,
border: '1px solid #ced4da',
fontSize: 16,
padding: '10px 26px 10px 12px',
transition: theme.transitions.create(['border-color', 'box-shadow']),
fontFamily: 'Helvetica Neue',
height: 25,
'&:focus': {
background: theme.palette.background.paper,
borderRadius: 4,
},
},
}),
)(InputBase);
interface DatePickerProps {
label: string;
onDateChange?: (date: string) => void;
}
export const DatePickerComponent = ({
label,
onDateChange,
...inputProps
}: InputProps & DatePickerProps) => (
<>
<Typography variant="button">{label}</Typography>
<BootstrapInput
inputProps={{ 'aria-label': label }}
type="date"
fullWidth
onChange={event => onDateChange?.(event.target.value)}
{...inputProps}
/>
</>
);
@@ -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 './DatePickerComponent';
@@ -13,69 +13,23 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { ReactChild } from 'react';
import React from 'react';
import {
ContentHeader,
SupportButton,
Progress,
StatusOK,
StatusError,
StatusWarning,
Table,
TableColumn,
EmptyState,
InfoCard,
} from '@backstage/core-components';
import { useApi } from '@backstage/core-plugin-api';
import { Build, BuildStatus, xcmetricsApiRef } from '../../api';
import { xcmetricsApiRef } from '../../api';
import { useAsync } from 'react-use';
import { Alert } from '@material-ui/lab';
import { StatusMatrixComponent } from '../StatusMatrixComponent';
import { formatDuration, formatTime } from '../../utils';
import { Chip, Grid } from '@material-ui/core';
import { Grid } from '@material-ui/core';
import { OverviewTrendsComponent } from '../OverviewTrendsComponent';
const STATUS_ICONS: { [key in BuildStatus]: ReactChild } = {
succeeded: <StatusOK />,
failed: <StatusError />,
stopped: <StatusWarning />,
};
const columns: TableColumn<Build>[] = [
{
field: 'buildStatus',
render: data => STATUS_ICONS[data.buildStatus],
},
{
title: 'Project',
field: 'projectName',
},
{
title: 'Schema',
field: 'schema',
},
{
title: 'Started',
field: 'startedAt',
searchable: false,
render: data => formatTime(data.startTimestamp),
},
{
title: 'Duration',
field: 'duration',
render: data => formatDuration(data.duration),
},
{
title: 'User',
field: 'userid',
},
{
field: 'isCI',
render: data => data.isCi && <Chip label="CI" size="small" />,
width: '10',
sorting: false,
},
];
import { overviewColumns } from '../BuildTableColumns';
export const OverviewComponent = () => {
const client = useApi(xcmetricsApiRef);
@@ -108,9 +62,14 @@ export const OverviewComponent = () => {
<Grid container spacing={3} direction="row">
<Grid item xs={12} md={8} lg={8} xl={9}>
<Table
options={{ paging: false, search: false }}
options={{
paging: false,
search: false,
sorting: false,
draggable: false,
}}
data={builds}
columns={columns}
columns={overviewColumns}
title={
<>
Latest Builds
@@ -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(
<ApiProvider
apis={ApiRegistry.with(xcmetricsApiRef, client.XcmetricsClient)}
>
<XcmetricsLayout />
</ApiProvider>,
);
expect(rendered.getByText('Overview')).toBeInTheDocument();
expect(rendered.getByText('Builds')).toBeInTheDocument();
expect(rendered.getByText('OverviewComponent')).toBeInTheDocument();
});
});
@@ -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: <OverviewComponent />,
},
{
routeRef: buildsRouteRef,
title: 'Builds',
component: <BuildListComponent />,
},
];
export const XcmetricsLayout = () => (
<Page themeId="tool">
@@ -23,8 +51,16 @@ export const XcmetricsLayout = () => (
<HeaderLabel label="Owner" value="Spotify" />
<HeaderLabel label="Lifecycle" value="Alpha" />
</Header>
<Content>
<OverviewComponent />
</Content>
<TabbedLayout>
{TABS.map(tab => (
<TabbedLayout.Route
key={tab.routeRef.path}
path={tab.routeRef.path}
title={tab.title}
>
<Content>{tab.component}</Content>
</TabbedLayout.Route>
))}
</TabbedLayout>
</Page>
);
+2 -1
View File
@@ -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();
});
});
+7 -1
View File
@@ -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',
});
+1 -2
View File
@@ -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}`;
};