Conslidate into one refreshing table
Signed-off-by: josh <josh.timmons@hashicorp.com>
This commit is contained in:
@@ -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());
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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} />
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user