Conslidate into one refreshing table

Signed-off-by: josh <josh.timmons@hashicorp.com>
This commit is contained in:
josh
2023-06-04 11:46:43 -04:00
parent 0215f93a5d
commit b1ba5f8f28
6 changed files with 287 additions and 193 deletions
+33 -23
View File
@@ -16,45 +16,37 @@
import { errorHandler, requestLoggingHandler } from '@backstage/backend-common';
import { Config } from '@backstage/config';
import { InputError } from '@backstage/errors';
import express from 'express';
import express, { RequestHandler } from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
export interface RouterOptions {
logger: Logger;
config: Config;
}
export async function createRouter(
const createEndpoint = (
options: RouterOptions,
): Promise<express.Router> {
endpoint: string,
): RequestHandler => {
const { config, logger } = options;
const router = Router();
router.use(express.json());
router.get('/health', (_, response) => {
logger.info('PONG!');
response.json({ status: 'ok' });
});
router.get('/v1/allocations', async (req, resp) => {
const addr = config.getString('nomad.addr');
const token = config.getOptionalString('nomad.token');
// Get Nomad addr and token from config
const addr = config.getString('nomad.addr');
const token = config.getOptionalString('nomad.token');
return async function (req, resp) {
// Check namespace argument
const namespace = (req.query.namespace as string) ?? '';
if (!namespace || namespace === '') {
throw new InputError(`Missing "namespace" query parameter`);
}
// Check filter argument
const filter = (req.query.filter as string) ?? '';
if (!filter || filter === '') {
throw new InputError(`Missing "filter" query parameter`);
}
logger.debug(
`/v1/allocations request headers: namespace=${namespace} filter=${filter}`,
`${endpoint} request headers: namespace=${namespace} filter=${filter}`,
);
// Issue the request
const allocationsResp = await fetch(
`${addr}/v1/allocations?namespace=${encodeURIComponent(
namespace,
@@ -68,11 +60,29 @@ export async function createRouter(
},
);
// Deserialize and return
const allocationsBody = await allocationsResp.json();
logger.debug(`/v1/allocations response: ${allocationsBody}`);
logger.debug(`${endpoint} response: ${allocationsBody}`);
resp.json(allocationsBody);
};
};
export interface RouterOptions {
logger: Logger;
config: Config;
}
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const router = Router();
router.use(express.json());
router.get('/health', (_, resp) => {
resp.json({ status: 'ok' });
});
router.get('/v1/allocations', createEndpoint(options, '/v1/allocations'));
router.get('/v1/deployments', createEndpoint(options, '/v1/deployments'));
router.use(requestLoggingHandler());
router.use(errorHandler());
+11 -3
View File
@@ -19,16 +19,20 @@ 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 { GroupListForEntity } from './components/GroupList/GroupListForEntity';
import { AllocationListTable } from './components/AllocationList/AllocationListTable';
/** @public */
export const NOMAD_NAMESPACE_ANNOTATION = 'nomad.io/namespace';
/** @public */
export const NOMAD_JOB_ID_ANNOTATION = 'nomad.io/job-id';
/** @public */
export const NOMAD_GROUP_ANNOTATION = 'nomad.io/group';
/** @public */
export const isNomadAvailable = (entity: Entity) =>
Boolean(entity.metadata.annotations?.[NOMAD_JOB_ID_ANNOTATION]) ||
Boolean(entity.metadata.annotations?.[NOMAD_GROUP_ANNOTATION]);
/** @public */
@@ -36,12 +40,16 @@ export const EmbeddedRouter = () => {
const { entity } = useEntity();
if (!isNomadAvailable(entity)) {
return <MissingAnnotationEmptyState annotation={NOMAD_GROUP_ANNOTATION} />;
return (
<MissingAnnotationEmptyState
annotation={[NOMAD_JOB_ID_ANNOTATION, NOMAD_GROUP_ANNOTATION]}
/>
);
}
return (
<Routes>
<Route path="/" element={<GroupListForEntity />} />
<Route path="/" element={<AllocationListTable />} />
</Routes>
);
};
+61
View File
@@ -35,6 +35,15 @@ export type NomadApi = {
listAllocations: (
options: ListAllocationsRequest,
) => Promise<ListAllocationsResponse>;
/**
* listDeployments 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>;
};
/** @public */
@@ -69,6 +78,40 @@ export interface DeploymentStatus {
Timestamp: string;
}
/** @public */
export interface ListDeploymentsRequest {
namespace: string;
filter: string;
}
/** @public */
export interface ListDeploymentsResponse {
deployments: Deployment[];
}
/** @public */
export interface Deployment {
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;
}
/** @public */
export class FetchError extends Error {
get name(): string {
@@ -109,4 +152,22 @@ export class NomadHttpApi implements NomadApi {
allocations: await resp.json(),
});
}
// TODO: pagination
async listDeployments(
options: ListDeploymentsRequest,
): Promise<ListDeploymentsResponse> {
const apiUrl = await this.discoveryApi.getBaseUrl('nomad');
const resp = await this.fetchApi.fetch(
`${apiUrl}/v1/deployments?namespace=${encodeURIComponent(
options.namespace,
)}&filter=${encodeURIComponent(options.filter)}`,
);
if (!resp.ok) throw await FetchError.forResponse(resp);
return Promise.resolve({
deployments: await resp.json(),
});
}
}
@@ -0,0 +1,182 @@
/*
* 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 {
Link,
ResponseErrorPanel,
StatusError,
StatusOK,
StatusPending,
StatusRunning,
Table,
TableColumn,
} from '@backstage/core-components';
import { useEntity } from '@backstage/plugin-catalog-react';
import React, { useState } from 'react';
import { Allocation, nomadApiRef } from '../../api';
import { configApiRef, useApi } from '@backstage/core-plugin-api';
import {
NOMAD_GROUP_ANNOTATION,
NOMAD_JOB_ID_ANNOTATION,
NOMAD_NAMESPACE_ANNOTATION,
} from '../../Router';
import useAsync from 'react-use/lib/useAsync';
type rowType = Allocation & { nomadAddr: string };
const columns: TableColumn<rowType>[] = [
{
title: 'ID',
field: 'ID',
render: row => (
<Link to={`${row.nomadAddr}/ui/allocations/${row.ID}`} underline="always">
{row.ID.split('-')[0]}
</Link>
),
},
{
title: 'Task Group',
field: 'TaskGroup',
render: row => (
<Link
to={`${row.nomadAddr}/ui/jobs/${row.JobID}/${row.TaskGroup}`}
underline="always"
>
{row.TaskGroup}
</Link>
),
},
{
title: 'Created',
field: 'CreateTime',
render: row => new Date(row.CreateTime / 1000000).toLocaleString(),
},
{
title: 'Status',
field: 'ClientStatus',
render: row =>
({
pending: <StatusPending>pending</StatusPending>,
running: <StatusOK>running</StatusOK>,
failed: <StatusError>failed</StatusError>,
complete: <StatusRunning>complete</StatusRunning>,
}[row.ClientStatus] || <text>{row.ClientStatus}</text>),
},
{
title: 'Version',
field: 'JobVersion',
render: row => row.JobVersion,
},
{
title: 'Client',
field: 'NodeID',
render: row => (
<Link to={`${row.nomadAddr}/ui/clients/${row.NodeID}`} underline="always">
{row.ID.split('-')[0]}
</Link>
),
},
];
/**
* AllocationListTable is roughly based off Nomad's Allocations tab's view.
*/
export const AllocationListTable = () => {
// 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 [allocations, setAllocations] = useState<rowType[]>([]);
const [err, setErr] = useState<Error>();
// Get plugin attributes
const namespace =
entity.metadata.annotations?.[NOMAD_NAMESPACE_ANNOTATION] ?? 'default';
const job = entity.metadata.annotations?.[NOMAD_JOB_ID_ANNOTATION] ?? '';
const group = entity.metadata.annotations?.[NOMAD_GROUP_ANNOTATION] ?? '';
// Make filter from attributes
const filter: string[] = [];
if (job) {
filter.push(`(JobID matches "${job}")`);
}
if (group) {
filter.push(`(TaskGroup matches "${group}")`);
}
// Create a query to update allocations
const query = async () => {
try {
// Make call to nomad-backend
const resp = await nomadApi.listAllocations({
namespace,
filter: filter.join(' and '),
});
// Sort results
const results = resp.allocations
.sort((a, b) => a.CreateTime - b.CreateTime)
.sort(({ ClientStatus: a }, { ClientStatus: b }) => {
if (a === 'running' || b !== 'running') {
return -1;
} else if (a === b || a !== 'running') {
return 0;
}
return 1;
});
setAllocations(results.map(row => ({ ...row, id: row.ID, nomadAddr })));
setErr(undefined);
} catch (e) {
setAllocations([]);
setErr(e);
return;
}
};
// Start querying for allocations every 5s
useAsync(async () => {
query();
return () => clearInterval(setInterval(query, 5000));
}, [allocations, entity]);
// Store a ref to a potential error
if (err) {
return <ResponseErrorPanel error={err} />;
}
return (
<Table<rowType>
title="Allocations"
options={{
search: true,
padding: 'dense',
sorting: true,
draggable: false,
paging: false,
debounceInterval: 500,
filterCellStyle: { padding: '0 16px 0 20px' },
}}
columns={columns}
data={allocations}
/>
);
};
@@ -1,96 +0,0 @@
/*
* 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 {
Link,
StatusError,
StatusOK,
StatusPending,
StatusRunning,
Table,
TableColumn,
} from '@backstage/core-components';
import React from 'react';
import { Allocation } from '../../api';
const columns: TableColumn<Allocation & { nomadAddr: string }>[] = [
{
title: 'ID',
render: row => (
<Link to={`${row.nomadAddr}/ui/allocations/${row.ID}`} underline="always">
{row.ID.split('-')[0]}
</Link>
),
},
{
title: 'Task Group',
render: row => (
<Link
to={`${row.nomadAddr}/ui/jobs/${row.JobID}/${row.TaskGroup}`}
underline="always"
>
{row.TaskGroup}
</Link>
),
},
{
title: 'Created',
render: row => new Date(row.CreateTime / 1000000).toLocaleString(),
},
{
title: 'Status',
render: row =>
({
pending: <StatusPending>pending</StatusPending>,
running: <StatusOK>running</StatusOK>,
failed: <StatusError>failed</StatusError>,
complete: <StatusRunning>complete</StatusRunning>,
}[row.ClientStatus] || <text>{row.ClientStatus}</text>),
},
{
title: 'Version',
render: row => row.JobVersion,
},
{
title: 'Client',
render: row => (
<Link to={`${row.nomadAddr}/ui/clients/${row.NodeID}`} underline="always">
{row.ID.split('-')[0]}
</Link>
),
},
];
/**
* AllocationListTable is roughly based off Nomad's Allocations tab's view.
*/
export const AllocationListTable = (props: {
allocations: Allocation[];
nomadAddr: string;
}) => (
<Table
options={{
paging: false,
toolbar: false,
}}
columns={columns}
data={props.allocations.map(allocation => ({
...allocation,
id: allocation.ID,
nomadAddr: props.nomadAddr,
}))}
/>
);
@@ -1,71 +0,0 @@
/*
* 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 { Progress } from '@backstage/core-components';
import React, { useState } from 'react';
import { AllocationListTable } from './AllocationListTable';
import { useEntity } from '@backstage/plugin-catalog-react';
import { Allocation, nomadApiRef } from '../../api';
import { configApiRef, useApi } from '@backstage/core-plugin-api';
import useAsync from 'react-use/lib/useAsync';
import {
NOMAD_GROUP_ANNOTATION,
NOMAD_NAMESPACE_ANNOTATION,
} from '../../Router';
import { Alert } from '@material-ui/lab';
export const GroupListForEntity = () => {
const { entity } = useEntity();
const namespace =
entity.metadata.annotations?.[NOMAD_NAMESPACE_ANNOTATION] ?? 'default';
const group = entity.metadata.annotations?.[NOMAD_GROUP_ANNOTATION] ?? '';
const configApi = useApi(configApiRef);
const nomadApi = useApi(nomadApiRef);
// Get the Nomad server address for links in the table
const nomadAddr = configApi.getString('nomad.addr');
// Retrieve allocations for the group
const [allocations, setAllocations] = useState<Allocation[]>([]);
const response = useAsync(async () => {
// Wait until entity is loaded
if (!entity) {
return;
}
// Issue call to nomad-backend
const resp = await nomadApi.listAllocations({
namespace,
filter: `TaskGroup == "${group}"`,
});
setAllocations(
resp.allocations.sort((a, b) => b.CreateTime - a.CreateTime),
);
}, [group]);
if (response.loading) {
return <Progress />;
}
if (response.error) {
return <Alert severity="error">{response.error.message}</Alert>;
}
return (
<AllocationListTable allocations={allocations} nomadAddr={nomadAddr} />
);
};