diff --git a/plugins/nomad-backend/src/service/router.ts b/plugins/nomad-backend/src/service/router.ts index 332fd004e1..161471f63a 100644 --- a/plugins/nomad-backend/src/service/router.ts +++ b/plugins/nomad-backend/src/service/router.ts @@ -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 { + 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 { + 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()); diff --git a/plugins/nomad/src/Router.tsx b/plugins/nomad/src/Router.tsx index 7615d6ad99..11c79c31d7 100644 --- a/plugins/nomad/src/Router.tsx +++ b/plugins/nomad/src/Router.tsx @@ -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 ; + return ( + + ); } return ( - } /> + } /> ); }; diff --git a/plugins/nomad/src/api.ts b/plugins/nomad/src/api.ts index b3057317fe..0cdba4a969 100644 --- a/plugins/nomad/src/api.ts +++ b/plugins/nomad/src/api.ts @@ -35,6 +35,15 @@ export type NomadApi = { listAllocations: ( options: ListAllocationsRequest, ) => Promise; + + /** + * 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; }; /** @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 { + 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(), + }); + } } diff --git a/plugins/nomad/src/components/AllocationList/AllocationListTable.tsx b/plugins/nomad/src/components/AllocationList/AllocationListTable.tsx new file mode 100644 index 0000000000..9130eb4007 --- /dev/null +++ b/plugins/nomad/src/components/AllocationList/AllocationListTable.tsx @@ -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[] = [ + { + title: 'ID', + field: 'ID', + render: row => ( + + {row.ID.split('-')[0]} + + ), + }, + { + title: 'Task Group', + field: 'TaskGroup', + render: row => ( + + {row.TaskGroup} + + ), + }, + { + title: 'Created', + field: 'CreateTime', + render: row => new Date(row.CreateTime / 1000000).toLocaleString(), + }, + { + title: 'Status', + field: 'ClientStatus', + render: row => + ({ + pending: pending, + running: running, + failed: failed, + complete: complete, + }[row.ClientStatus] || {row.ClientStatus}), + }, + { + title: 'Version', + field: 'JobVersion', + render: row => row.JobVersion, + }, + { + title: 'Client', + field: 'NodeID', + render: row => ( + + {row.ID.split('-')[0]} + + ), + }, +]; + +/** + * 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([]); + const [err, setErr] = useState(); + + // 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 ; + } + + return ( + + 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} + /> + ); +}; diff --git a/plugins/nomad/src/components/GroupList/AllocationListTable.tsx b/plugins/nomad/src/components/GroupList/AllocationListTable.tsx deleted file mode 100644 index bfabfca3a9..0000000000 --- a/plugins/nomad/src/components/GroupList/AllocationListTable.tsx +++ /dev/null @@ -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[] = [ - { - title: 'ID', - render: row => ( - - {row.ID.split('-')[0]} - - ), - }, - { - title: 'Task Group', - render: row => ( - - {row.TaskGroup} - - ), - }, - { - title: 'Created', - render: row => new Date(row.CreateTime / 1000000).toLocaleString(), - }, - { - title: 'Status', - render: row => - ({ - pending: pending, - running: running, - failed: failed, - complete: complete, - }[row.ClientStatus] || {row.ClientStatus}), - }, - { - title: 'Version', - render: row => row.JobVersion, - }, - { - title: 'Client', - render: row => ( - - {row.ID.split('-')[0]} - - ), - }, -]; - -/** - * AllocationListTable is roughly based off Nomad's Allocations tab's view. - */ -export const AllocationListTable = (props: { - allocations: Allocation[]; - nomadAddr: string; -}) => ( - ({ - ...allocation, - id: allocation.ID, - nomadAddr: props.nomadAddr, - }))} - /> -); diff --git a/plugins/nomad/src/components/GroupList/GroupListForEntity.tsx b/plugins/nomad/src/components/GroupList/GroupListForEntity.tsx deleted file mode 100644 index 9586136fec..0000000000 --- a/plugins/nomad/src/components/GroupList/GroupListForEntity.tsx +++ /dev/null @@ -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([]); - 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 ; - } - if (response.error) { - return {response.error.message}; - } - - return ( - - ); -};