Add Build list page

Signed-off-by: Niklas Granander <ngranander@spotify.com>
This commit is contained in:
Niklas Granander
2021-07-29 16:53:38 +02:00
parent 71b5cc1ba8
commit 4509e18356
11 changed files with 489 additions and 8 deletions
@@ -16,9 +16,11 @@
import { DiscoveryApi } from '@backstage/core-plugin-api';
import { ResponseError } from '@backstage/errors';
import { DateTime } from 'luxon';
import {
Build,
BuildCount,
BuildStatus,
BuildStatusResult,
BuildTime,
PaginationResult,
@@ -58,6 +60,38 @@ export class XcmetricsClient implements XcmetricsApi {
return ((await response.json()) as PaginationResult<Build>).items;
}
async getFilteredBuilds(
from: string,
to: string,
status?: BuildStatus,
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(from)
.startOf('day')
.toISO({ suppressMilliseconds: true }),
to: DateTime.fromISO(to)
.endOf('day')
.startOf('second')
.toISO({ suppressMilliseconds: true }),
status,
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(
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { Build, XcmetricsApi } from '../types';
import { Build, BuildStatus, XcmetricsApi } from '../types';
export const mockBuild = {
userid: 'userid1',
@@ -61,6 +61,22 @@ export const XcmetricsClient: XcmetricsApi = {
{ ...mockBuild, id: '2', userid: 'userid2' },
]);
},
getFilteredBuilds: (
from: string,
to: string,
status?: BuildStatus,
page?: number,
perPage?: number,
) => {
return Promise.resolve({
items: [mockBuild],
metadata: {
per: 10,
total: 1,
page: 1,
},
});
},
getBuildCounts: () => {
return Promise.resolve([mockBuildCount, mockBuildCount]);
},
+16 -1
View File
@@ -70,7 +70,22 @@ export type PaginationResult<T> = {
export interface XcmetricsApi {
getBuild(id: string): Promise<Build>;
getBuilds(): Promise<Build[]>;
getBuilds(limit?: number): Promise<Build[]>;
/**
* Get builds filtered by the provided parameters
*
* @param from Builds after this date. An ISO date in a string (e.g. "2020-01-01")
* @param to Builds before this date. An ISO date in a string (e.g. "2021-01-01")
* @param status Builds with this status
*/
getFilteredBuilds(
from: string,
to: string,
status?: BuildStatus,
page?: number,
perPage?: number,
): Promise<PaginationResult<Build>>;
getBuildCounts(days: number): Promise<BuildCount[]>;
getBuildTimes(days: number): Promise<BuildTime[]>;
getBuildStatuses(limit: number): Promise<BuildStatusResult[]>;
@@ -0,0 +1,107 @@
/*
* 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, useRef, useState } from 'react';
import { Table, TableColumn } from '@backstage/core-components';
import { useApi } from '@backstage/core-plugin-api';
import { Build, xcmetricsApiRef } from '../../api';
import { formatDuration, formatTime } from '../../utils';
import { Chip, Grid } from '@material-ui/core';
import {
ActiveFilters,
BuildListFilterComponent as Filters,
} from '../BuildListFilterComponent';
import { DateTime } from 'luxon';
const columns: TableColumn<Build>[] = [
{
title: 'Status',
field: '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,
},
];
export const BuildListComponent = () => {
const initDates = {
from: DateTime.now().minus({ year: 1 }).toISODate(),
to: DateTime.now().toISODate(),
};
const client = useApi(xcmetricsApiRef);
const tableRef = useRef<any>();
const [filters, setFilters] = useState<ActiveFilters>(initDates);
useEffect(() => tableRef.current?.onQueryChange(), [filters]);
return (
<Grid container spacing={3} direction="column">
<Filters onFilterChange={setFilters} initDates={initDates} />
<Table
tableRef={tableRef}
options={{ paging: true, sorting: false, search: false, pageSize: 10 }}
data={query => {
return new Promise((resolve, reject) => {
if (!query) return;
client
.getFilteredBuilds(
filters.from,
filters.to,
filters.buildStatus,
query.page + 1, // Page starts at 1 in API
query.pageSize,
)
.then(result => {
resolve({
data: result.items,
page: result.metadata.page - 1,
totalCount: result.metadata.total,
});
})
.catch(reason => reject(reason));
});
}}
columns={columns}
title="Builds"
/>
</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,136 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
makeStyles,
useTheme,
IconButton,
Grid,
Button,
} from '@material-ui/core';
import FilterList from '@material-ui/icons/FilterList';
import React, { useEffect, useState } from 'react';
import { InfoCard, Select } from '@backstage/core-components';
import { BackstageTheme } from '@backstage/theme';
import { BuildStatus } from '../../api';
import { DatePickerComponent } from '../DatePickerComponent';
export type ActiveFilters = {
from: string;
to: string;
buildStatus?: BuildStatus;
};
const useStyles = makeStyles<BackstageTheme>(theme => ({
filtersContent: {
padding: theme.spacing(2, 2, 2, 2.5),
},
}));
type FilterOption<T> = T | 'all';
interface FiltersProps {
initDates: { from: string; to: string };
onFilterChange: (filters: ActiveFilters) => void;
}
export const BuildListFilterComponent = ({
onFilterChange,
initDates,
}: FiltersProps) => {
const classes = useStyles(useTheme<BackstageTheme>());
const [status, setStatus] = useState<BuildStatus | undefined>();
const [from, setFrom] = useState<string>(initDates.from);
const [to, setTo] = useState<string>(initDates.to);
const [open, setOpen] = useState(false);
useEffect(() => onFilterChange({ from, to, buildStatus: status }), [
onFilterChange,
from,
to,
status,
]);
const numFilters =
Number(!!status) +
Number(from !== initDates.from) +
Number(to !== initDates.to);
const clear = () => {
setStatus(undefined);
setFrom(initDates.from);
setTo(initDates.to);
};
const title = (
<>
<IconButton onClick={() => setOpen(!open)} aria-label="filter list">
<FilterList />
</IconButton>
Filters ({numFilters})
{!!numFilters && (
<Button color="primary" onClick={clear}>
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 content = (
<Grid
container
spacing={3}
direction="row"
className={classes.filtersContent}
>
<Grid item xs={2}>
<DatePickerComponent label="From" value={from} onDateChange={setFrom} />
</Grid>
<Grid item xs={2}>
<DatePickerComponent label="To" value={to} onDateChange={setTo} />
</Grid>
<Grid item xs={2}>
<Select
label="Status"
items={statusItems}
selected={!status ? 'all' : status}
onChange={arg =>
setStatus(arg === 'all' ? undefined : (arg as BuildStatus))
}
/>
</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,83 @@
/*
* 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,
makeStyles,
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);
const useStyles = makeStyles({
root: {
display: 'flex',
flexDirection: 'column',
},
});
interface DatePickerProps {
label: string;
onDateChange?: (date: string) => void;
}
export const DatePickerComponent = ({
label,
...inputProps
}: InputProps & DatePickerProps) => {
const classes = useStyles();
return (
<div className={classes.root}>
<Typography variant="button">{label}</Typography>
<BootstrapInput
type="date"
fullWidth
{...inputProps}
onChange={event => inputProps.onDateChange?.(event.target.value)}
/>
</div>
);
};
@@ -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,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>
);
+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',
});