Add Build list page
Signed-off-by: Niklas Granander <ngranander@spotify.com>
This commit is contained in:
@@ -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]);
|
||||
},
|
||||
|
||||
@@ -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';
|
||||
+136
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user