Add job versions card

Signed-off-by: josh <josh.timmons@hashicorp.com>
This commit is contained in:
josh
2023-06-04 14:48:59 -04:00
parent b1ba5f8f28
commit 285de62914
15 changed files with 269 additions and 363 deletions
+8 -4
View File
@@ -19,7 +19,7 @@ import { Entity } from '@backstage/catalog-model';
import { useEntity } from '@backstage/plugin-catalog-react';
import { MissingAnnotationEmptyState } from '@backstage/core-components';
import { Route, Routes } from 'react-router-dom';
import { AllocationListTable } from './components/AllocationList/AllocationListTable';
import { EntityNomadAllocationListTable } from './components/EntityNomadAllocationListTable/EntityNomadAllocationListTable';
/** @public */
export const NOMAD_NAMESPACE_ANNOTATION = 'nomad.io/namespace';
@@ -31,7 +31,11 @@ export const NOMAD_JOB_ID_ANNOTATION = 'nomad.io/job-id';
export const NOMAD_GROUP_ANNOTATION = 'nomad.io/group';
/** @public */
export const isNomadAvailable = (entity: Entity) =>
export const isNomadJobIDAvailable = (entity: Entity) =>
Boolean(entity.metadata.annotations?.[NOMAD_JOB_ID_ANNOTATION]);
/** @public */
export const isNomadAllocationsAvailable = (entity: Entity) =>
Boolean(entity.metadata.annotations?.[NOMAD_JOB_ID_ANNOTATION]) ||
Boolean(entity.metadata.annotations?.[NOMAD_GROUP_ANNOTATION]);
@@ -39,7 +43,7 @@ export const isNomadAvailable = (entity: Entity) =>
export const EmbeddedRouter = () => {
const { entity } = useEntity();
if (!isNomadAvailable(entity)) {
if (!isNomadAllocationsAvailable(entity)) {
return (
<MissingAnnotationEmptyState
annotation={[NOMAD_JOB_ID_ANNOTATION, NOMAD_GROUP_ANNOTATION]}
@@ -49,7 +53,7 @@ export const EmbeddedRouter = () => {
return (
<Routes>
<Route path="/" element={<AllocationListTable />} />
<Route path="/" element={<EntityNomadAllocationListTable />} />
</Routes>
);
};
+21 -34
View File
@@ -37,13 +37,13 @@ export type NomadApi = {
) => Promise<ListAllocationsResponse>;
/**
* listDeployments is for listing all deployments matching some part of 'filter'.
* listJobVersions is for listing all deployments matching some part of 'filter'.
*
* See: https://developer.hashicorp.com/nomad/api-docs/deployments#list-deployments
*/
listDeployments: (
options: ListDeploymentsRequest,
) => Promise<ListDeploymentsResponse>;
listJobVersions: (
options: ListJobVersionsRequest,
) => Promise<ListJobVersionsResponse>;
};
/** @public */
@@ -79,37 +79,22 @@ export interface DeploymentStatus {
}
/** @public */
export interface ListDeploymentsRequest {
export interface ListJobVersionsRequest {
namespace: string;
filter: string;
jobID: string;
}
/** @public */
export interface ListDeploymentsResponse {
deployments: Deployment[];
export interface ListJobVersionsResponse {
versions: Version[];
}
/** @public */
export interface Deployment {
export interface Version {
ID: string;
JobID: string;
JobVersion: number;
JobModifyIndex: number;
TaskGroups: {
[taskGroup: string]: {
Promoted: boolean;
DesiredCanaries: number;
DesiredTotal: number;
PlacedAllocs: number;
HealthyAllocs: number;
UnhealthyAllocs: number;
};
};
JobSpecModifyIndex: number;
JobCreateIndex: number;
Status: string;
CreateIndex: number;
ModifyIndex: number;
SubmitTime: number;
Stable: boolean;
Version: number;
}
/** @public */
@@ -154,20 +139,22 @@ export class NomadHttpApi implements NomadApi {
}
// TODO: pagination
async listDeployments(
options: ListDeploymentsRequest,
): Promise<ListDeploymentsResponse> {
async listJobVersions(
options: ListJobVersionsRequest,
): Promise<ListJobVersionsResponse> {
const apiUrl = await this.discoveryApi.getBaseUrl('nomad');
const resp = await this.fetchApi.fetch(
`${apiUrl}/v1/deployments?namespace=${encodeURIComponent(
options.namespace,
)}&filter=${encodeURIComponent(options.filter)}`,
`${apiUrl}/v1/job/${
options.jobID
}/versions?namespace=${encodeURIComponent(options.namespace)}`,
);
if (!resp.ok) throw await FetchError.forResponse(resp);
const respJson = await resp.json();
return Promise.resolve({
deployments: await resp.json(),
versions: respJson.Versions,
});
}
}
@@ -16,6 +16,7 @@
import {
Link,
MissingAnnotationEmptyState,
ResponseErrorPanel,
StatusError,
StatusOK,
@@ -32,6 +33,7 @@ import {
NOMAD_GROUP_ANNOTATION,
NOMAD_JOB_ID_ANNOTATION,
NOMAD_NAMESPACE_ANNOTATION,
isNomadAllocationsAvailable,
} from '../../Router';
import useAsync from 'react-use/lib/useAsync';
@@ -92,13 +94,14 @@ const columns: TableColumn<rowType>[] = [
];
/**
* AllocationListTable is roughly based off Nomad's Allocations tab's view.
* EntityNomadAllocationListTable is roughly based off Nomad's Allocations tab's view.
*/
export const AllocationListTable = () => {
export const EntityNomadAllocationListTable = () => {
// Wait on entity
const { entity } = useEntity();
// Get ref to the backend API
const [init, setInit] = useState(true);
const configApi = useApi(configApiRef);
const nomadApi = useApi(nomadApiRef);
const nomadAddr = configApi.getString('nomad.addr');
@@ -107,6 +110,13 @@ export const AllocationListTable = () => {
const [allocations, setAllocations] = useState<rowType[]>([]);
const [err, setErr] = useState<Error>();
// Check that attributes are available
if (!isNomadAllocationsAvailable(entity)) {
<MissingAnnotationEmptyState
annotation={[NOMAD_JOB_ID_ANNOTATION, NOMAD_GROUP_ANNOTATION]}
/>;
}
// Get plugin attributes
const namespace =
entity.metadata.annotations?.[NOMAD_NAMESPACE_ANNOTATION] ?? 'default';
@@ -137,10 +147,8 @@ export const AllocationListTable = () => {
.sort(({ ClientStatus: a }, { ClientStatus: b }) => {
if (a === 'running' || b !== 'running') {
return -1;
} else if (a === b || a !== 'running') {
return 0;
}
return 1;
return 0;
});
setAllocations(results.map(row => ({ ...row, id: row.ID, nomadAddr })));
@@ -148,14 +156,21 @@ export const AllocationListTable = () => {
} catch (e) {
setAllocations([]);
setErr(e);
return;
}
};
// Start querying for allocations every 5s
useAsync(async () => {
query();
return () => clearInterval(setInterval(query, 5000));
if (init) {
setInit(false);
query();
}
const interval = setTimeout(() => {
query();
}, 5_000);
return () => clearTimeout(interval);
}, [allocations, entity]);
// Store a ref to a potential error
@@ -0,0 +1,146 @@
/*
* Copyright 2020 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 {
InfoCard,
MissingAnnotationEmptyState,
ResponseErrorPanel,
Table,
TableColumn,
} from '@backstage/core-components';
import { useEntity } from '@backstage/plugin-catalog-react';
import React, { useEffect, useState } from 'react';
import { Version, nomadApiRef } from '../../api';
import { configApiRef, useApi } from '@backstage/core-plugin-api';
import {
NOMAD_JOB_ID_ANNOTATION,
NOMAD_NAMESPACE_ANNOTATION,
isNomadJobIDAvailable,
} from '../../Router';
import OpenInNewIcon from '@material-ui/icons/OpenInNew';
import { Chip } from '@material-ui/core';
type rowType = Version & { nomadAddr: string };
const columns: TableColumn<rowType>[] = [
{
title: 'Version',
field: 'Version',
render: row => row.Version,
},
{
title: 'Stable',
field: 'Stable',
render: row => <Chip label={`${row.Stable}`} />,
},
{
title: 'Submitted',
field: 'SubmitTime',
render: row => new Date(row.SubmitTime / 1000000).toLocaleString(),
},
];
/**
* EntityNomadJobVersionListCard is roughly based on the Nomad UI's versions tab.
*/
export const EntityNomadJobVersionListCard = () => {
// Wait on entity
const { entity } = useEntity();
// Get ref to the backend API
const configApi = useApi(configApiRef);
const nomadApi = useApi(nomadApiRef);
const nomadAddr = configApi.getString('nomad.addr');
// Store results of calling API
const [init, setInit] = useState(true);
const [versions, setVersions] = useState<rowType[]>([]);
const [err, setErr] = useState<Error>();
// Get plugin attributes
const namespace =
entity.metadata.annotations?.[NOMAD_NAMESPACE_ANNOTATION] ?? 'default';
const jobID = entity.metadata.annotations?.[NOMAD_JOB_ID_ANNOTATION] ?? '';
// Start querying for allocations every 10s
useEffect(() => {
// Create a query to update allocations
const query = async () => {
try {
// Make call to nomad-backend
const resp = await nomadApi.listJobVersions({ namespace, jobID });
setVersions(
resp.versions.map(row => ({ ...row, id: row.ID, nomadAddr })),
);
setErr(undefined);
} catch (e) {
setVersions([]);
setErr(e);
}
};
if (init) {
setInit(false);
query();
}
const interval = setTimeout(() => {
query();
}, 10_000);
return () => clearTimeout(interval);
}, [init, jobID, namespace, nomadAddr, nomadApi]);
// Store a ref to a potential error
if (err) {
return <ResponseErrorPanel error={err} />;
}
// Check that job ID is set
if (!isNomadJobIDAvailable(entity)) {
return (
<InfoCard title="Job Versions">
<MissingAnnotationEmptyState annotation={NOMAD_JOB_ID_ANNOTATION} />
</InfoCard>
);
}
return (
<Table<rowType>
title="Job Versions"
actions={[
{
icon: () => <OpenInNewIcon />,
tooltip: 'Open Job Versions Tab in Nomad',
isFreeAction: true,
onClick: () => window.open(`${nomadAddr}/ui/jobs/${jobID}/versions`),
},
]}
options={{
search: false,
padding: 'dense',
sorting: true,
draggable: false,
paging: false,
debounceInterval: 500,
filterCellStyle: { padding: '0 16px 0 20px' },
}}
columns={columns}
data={versions}
/>
);
};
@@ -1,42 +0,0 @@
/*
* Copyright 2023 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 { NomadComponent } from './NomadComponent';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { screen } from '@testing-library/react';
import {
setupRequestMockHandlers,
renderInTestApp,
} from '@backstage/test-utils';
describe('NomadComponent', () => {
const server = setupServer();
// Enable sane handlers for network requests
setupRequestMockHandlers(server);
// setup mock response
beforeEach(() => {
server.use(
rest.get('/*', (_, res, ctx) => res(ctx.status(200), ctx.json({}))),
);
});
it('should render', async () => {
await renderInTestApp(<NomadComponent />);
expect(screen.getByText('Welcome to nomad!')).toBeInTheDocument();
});
});
@@ -1,53 +0,0 @@
/*
* Copyright 2023 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 { Typography, Grid } from '@material-ui/core';
import {
InfoCard,
Header,
Page,
Content,
ContentHeader,
HeaderLabel,
SupportButton,
} from '@backstage/core-components';
import { NomadFetchComponent } from '../NomadFetchComponent';
export const NomadComponent = () => (
<Page themeId="tool">
<Header title="Welcome to nomad!" subtitle="Optional subtitle">
<HeaderLabel label="Owner" value="Team X" />
<HeaderLabel label="Lifecycle" value="Alpha" />
</Header>
<Content>
<ContentHeader title="Plugin title">
<SupportButton>A description of your plugin goes here.</SupportButton>
</ContentHeader>
<Grid container spacing={3} direction="column">
<Grid item>
<InfoCard title="Information card">
<Typography variant="body1">
All content should be wrapped in a card like this.
</Typography>
</InfoCard>
</Grid>
<Grid item>
<NomadFetchComponent />
</Grid>
</Grid>
</Content>
</Page>
);
@@ -1,16 +0,0 @@
/*
* Copyright 2023 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 { NomadComponent } from './NomadComponent';
@@ -1,40 +0,0 @@
/*
* Copyright 2023 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 { render, screen } from '@testing-library/react';
import { NomadFetchComponent } from './NomadFetchComponent';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { setupRequestMockHandlers } from '@backstage/test-utils';
describe('NomadFetchComponent', () => {
const server = setupServer();
// Enable sane handlers for network requests
setupRequestMockHandlers(server);
// setup mock response
beforeEach(() => {
server.use(
rest.get('https://randomuser.me/*', (_, res, ctx) =>
res(ctx.status(200), ctx.delay(2000), ctx.json({})),
),
);
});
it('should render', async () => {
await render(<NomadFetchComponent />);
expect(await screen.findByTestId('progress')).toBeInTheDocument();
});
});
@@ -1,111 +0,0 @@
/*
* Copyright 2023 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 { makeStyles } from '@material-ui/core/styles';
import {
Table,
TableColumn,
Progress,
ResponseErrorPanel,
} from '@backstage/core-components';
import { fetchApiRef, useApi } from '@backstage/core-plugin-api';
import useAsync from 'react-use/lib/useAsync';
const useStyles = makeStyles({
avatar: {
height: 32,
width: 32,
borderRadius: '50%',
},
});
type User = {
gender: string; // "male"
name: {
title: string; // "Mr",
first: string; // "Duane",
last: string; // "Reed"
};
location: object; // {street: {number: 5060, name: "Hickory Creek Dr"}, city: "Albany", state: "New South Wales",…}
email: string; // "duane.reed@example.com"
login: object; // {uuid: "4b785022-9a23-4ab9-8a23-cb3fb43969a9", username: "blackdog796", password: "patch",…}
dob: object; // {date: "1983-06-22T12:30:23.016Z", age: 37}
registered: object; // {date: "2006-06-13T18:48:28.037Z", age: 14}
phone: string; // "07-2154-5651"
cell: string; // "0405-592-879"
id: {
name: string; // "TFN",
value: string; // "796260432"
};
picture: { medium: string }; // {medium: "https://randomuser.me/api/portraits/men/95.jpg",…}
nat: string; // "AU"
};
type DenseTableProps = {
users: User[];
};
export const DenseTable = ({ users }: DenseTableProps) => {
const classes = useStyles();
const columns: TableColumn[] = [
{ title: 'Avatar', field: 'avatar' },
{ title: 'Name', field: 'name' },
{ title: 'Email', field: 'email' },
{ title: 'Nationality', field: 'nationality' },
];
const data = users.map(user => {
return {
avatar: (
<img
src={user.picture.medium}
className={classes.avatar}
alt={user.name.first}
/>
),
name: `${user.name.first} ${user.name.last}`,
email: user.email,
nationality: user.nat,
};
});
return (
<Table
title="Example User List (fetching data from randomuser.me)"
options={{ search: false, paging: false }}
columns={columns}
data={data}
/>
);
};
export const NomadFetchComponent = () => {
const { fetch } = useApi(fetchApiRef);
const { value, loading, error } = useAsync(async (): Promise<User[]> => {
const response = await fetch('https://randomuser.me/api/?results=20');
const data = await response.json();
return data.results;
}, []);
if (loading) {
return <Progress />;
} else if (error) {
return <ResponseErrorPanel error={error} />;
}
return <DenseTable users={value || []} />;
};
@@ -13,4 +13,6 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { NomadFetchComponent } from './NomadFetchComponent';
import { EntityNomadJobVersionListCard } from './EntityNomadJobVersionListCard/EntityNomadJobVersionListCard';
export { EntityNomadJobVersionListCard };
+7 -2
View File
@@ -13,5 +13,10 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { nomadPlugin, NomadPage, EntityNomadContent } from './plugin';
export { isNomadAvailable, EmbeddedRouter } from './Router';
export { nomadPlugin } from './plugin';
export {
isNomadAllocationsAvailable,
isNomadJobIDAvailable,
EmbeddedRouter,
} from './Router';
export { EntityNomadJobVersionListCard } from './components';
-19
View File
@@ -44,22 +44,3 @@ export const nomadPlugin = createPlugin({
entityContent: entityContentRouteRef,
},
});
/** @public */
export const NomadPage = nomadPlugin.provide(
createRoutableExtension({
name: 'NomadPage',
component: () =>
import('./components/NomadComponent').then(m => m.NomadComponent),
mountPoint: rootRouteRef,
}),
);
/** @public */
export const EntityNomadContent = nomadPlugin.provide(
createRoutableExtension({
name: 'EntityNomadContent',
component: () => import('./Router').then(m => m.EmbeddedRouter),
mountPoint: entityContentRouteRef,
}),
);