Merge pull request #6671 from ngranander/xcmetrics-builds-page
Xcmetrics builds page
This commit is contained in:
@@ -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]);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
+158
@@ -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();
|
||||
});
|
||||
});
|
||||
+158
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user