diff --git a/.changeset/friendly-glasses-sleep.md b/.changeset/friendly-glasses-sleep.md
new file mode 100644
index 0000000000..fa890eab06
--- /dev/null
+++ b/.changeset/friendly-glasses-sleep.md
@@ -0,0 +1,6 @@
+---
+'@backstage/plugin-nomad-backend': minor
+'@backstage/plugin-nomad': minor
+---
+
+Initialize Nomad plugin with frontend and backend. List jobs/allocations
diff --git a/packages/app/package.json b/packages/app/package.json
index efa1faf6c5..75aa8e5afc 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -50,6 +50,7 @@
"@backstage/plugin-microsoft-calendar": "workspace:^",
"@backstage/plugin-newrelic": "workspace:^",
"@backstage/plugin-newrelic-dashboard": "workspace:^",
+ "@backstage/plugin-nomad": "workspace:^",
"@backstage/plugin-octopus-deploy": "workspace:^",
"@backstage/plugin-org": "workspace:^",
"@backstage/plugin-pagerduty": "workspace:^",
diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx
index ca6d09e318..be5ae3e3bf 100644
--- a/packages/app/src/components/catalog/EntityPage.tsx
+++ b/packages/app/src/components/catalog/EntityPage.tsx
@@ -112,6 +112,12 @@ import {
EntityOwnershipCard,
EntityUserProfileCard,
} from '@backstage/plugin-org';
+import {
+ EntityNomadAllocationListTable,
+ EntityNomadJobVersionListCard,
+ isNomadAllocationsAvailable,
+ isNomadJobIDAvailable,
+} from '@backstage/plugin-nomad';
import {
EntityPagerDutyCard,
isPagerDutyAvailable,
@@ -442,6 +448,14 @@ const overviewContent = (
+
+
+
+
+
+
+
+
);
@@ -501,6 +515,14 @@ const serviceEntityPage = (
+
+
+
+
{pullRequestsContent}
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 91c7e2cdd5..85aafc0f1c 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -54,6 +54,7 @@
"@backstage/plugin-kubernetes-backend": "workspace:^",
"@backstage/plugin-lighthouse-backend": "workspace:^",
"@backstage/plugin-linguist-backend": "workspace:^",
+ "@backstage/plugin-nomad-backend": "workspace:^",
"@backstage/plugin-permission-backend": "workspace:^",
"@backstage/plugin-permission-common": "workspace:^",
"@backstage/plugin-permission-node": "workspace:^",
diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts
index 5d22ec92e8..54f0f98e41 100644
--- a/packages/backend/src/index.ts
+++ b/packages/backend/src/index.ts
@@ -65,6 +65,7 @@ import adr from './plugins/adr';
import lighthouse from './plugins/lighthouse';
import linguist from './plugins/linguist';
import devTools from './plugins/devtools';
+import nomad from './plugins/nomad';
import { PluginEnvironment } from './types';
import { ServerPermissionClient } from '@backstage/plugin-permission-node';
import { DefaultIdentityClient } from '@backstage/plugin-auth-node';
@@ -172,6 +173,7 @@ async function main() {
const lighthouseEnv = useHotMemoize(module, () => createEnv('lighthouse'));
const linguistEnv = useHotMemoize(module, () => createEnv('linguist'));
const devToolsEnv = useHotMemoize(module, () => createEnv('devtools'));
+ const nomadEnv = useHotMemoize(module, () => createEnv('nomad'));
const apiRouter = Router();
apiRouter.use('/catalog', await catalog(catalogEnv));
@@ -198,6 +200,7 @@ async function main() {
apiRouter.use('/adr', await adr(adrEnv));
apiRouter.use('/linguist', await linguist(linguistEnv));
apiRouter.use('/devtools', await devTools(devToolsEnv));
+ apiRouter.use('/nomad', await nomad(nomadEnv));
apiRouter.use(notFoundHandler());
await lighthouse(lighthouseEnv);
diff --git a/packages/backend/src/plugins/nomad.ts b/packages/backend/src/plugins/nomad.ts
new file mode 100644
index 0000000000..111cbfbf0d
--- /dev/null
+++ b/packages/backend/src/plugins/nomad.ts
@@ -0,0 +1,24 @@
+/*
+ * 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 { createRouter } from '@backstage/plugin-nomad-backend';
+import { Router } from 'express';
+import { PluginEnvironment } from '../types';
+
+export default async function createPlugin(
+ props: PluginEnvironment,
+): Promise {
+ return await createRouter(props);
+}
diff --git a/plugins/nomad-backend/.eslintrc.js b/plugins/nomad-backend/.eslintrc.js
new file mode 100644
index 0000000000..e2a53a6ad2
--- /dev/null
+++ b/plugins/nomad-backend/.eslintrc.js
@@ -0,0 +1 @@
+module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
diff --git a/plugins/nomad-backend/README.md b/plugins/nomad-backend/README.md
new file mode 100644
index 0000000000..d06444809e
--- /dev/null
+++ b/plugins/nomad-backend/README.md
@@ -0,0 +1,50 @@
+# @backstage/plugin-nomad-backend
+
+A backend for [Nomad](https://www.nomadproject.io/), this plugin exposes a service with routes that are used by the `@backstage/plugin-nomad` plugin to query Job and Group information from a Nomad API.
+
+## Set Up
+
+1. Install the plugin using:
+
+```bash
+# From your Backstage root directory
+yarn add --cwd packages/backend @backstage/plugin-nomad-backend
+```
+
+2. Create a `nomad.ts` file inside `packages/backend/src/plugins/`:
+
+```typescript
+import { createRouter } from '@backstage/plugin-nomad-backend';
+import { Router } from 'express';
+import { PluginEnvironment } from '../types';
+
+export default async function createPlugin(
+ props: PluginEnvironment,
+): Promise {
+ return await createRouter(props);
+}
+```
+
+3. Modify your `packages/backend/src/index.ts` to include:
+
+```diff
+ ...
+
+ import { Config } from '@backstage/config';
+ import app from './plugins/app';
++import nomad from './plugins/nomad';
+ ...
+
+ async function main() {
+ ...
+
+ const authEnv = useHotMemoize(module, () => createEnv('auth'));
++ const nomadEnv = useHotMemoize(module, () => createEnv('nomad'));
+ ...
+
+ const apiRouter = Router();
+ apiRouter.use('/catalog', await catalog(catalogEnv));
++ apiRouter.use('/nomad', await nomad(nomadEnv));
+```
+
+Note: for this backend to work, the `nomad` configuration described in the README of `@backstage/plugin-nomad` must be implemented.
diff --git a/plugins/nomad-backend/api-report.md b/plugins/nomad-backend/api-report.md
new file mode 100644
index 0000000000..5b56661e43
--- /dev/null
+++ b/plugins/nomad-backend/api-report.md
@@ -0,0 +1,22 @@
+## API Report File for "@backstage/plugin-nomad-backend"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+import { Config } from '@backstage/config';
+import express from 'express';
+import { Logger } from 'winston';
+
+// @public (undocumented)
+export function createRouter(options: RouterOptions): Promise;
+
+// @public (undocumented)
+export interface RouterOptions {
+ // (undocumented)
+ config: Config;
+ // (undocumented)
+ logger: Logger;
+}
+
+// (No @packageDocumentation comment for this package)
+```
diff --git a/plugins/nomad-backend/package.json b/plugins/nomad-backend/package.json
new file mode 100644
index 0000000000..344d120001
--- /dev/null
+++ b/plugins/nomad-backend/package.json
@@ -0,0 +1,44 @@
+{
+ "name": "@backstage/plugin-nomad-backend",
+ "version": "0.0.0",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "license": "Apache-2.0",
+ "publishConfig": {
+ "access": "public",
+ "main": "dist/index.cjs.js",
+ "types": "dist/index.d.ts"
+ },
+ "backstage": {
+ "role": "backend-plugin"
+ },
+ "scripts": {
+ "start": "backstage-cli package start",
+ "build": "backstage-cli package build",
+ "lint": "backstage-cli package lint",
+ "test": "backstage-cli package test",
+ "clean": "backstage-cli package clean",
+ "prepack": "backstage-cli package prepack",
+ "postpack": "backstage-cli package postpack"
+ },
+ "dependencies": {
+ "@backstage/backend-common": "workspace:^",
+ "@backstage/config": "workspace:^",
+ "@backstage/errors": "workspace:^",
+ "@types/express": "*",
+ "express": "^4.17.1",
+ "express-promise-router": "^4.1.0",
+ "node-fetch": "^2.6.7",
+ "winston": "^3.2.1",
+ "yn": "^4.0.0"
+ },
+ "devDependencies": {
+ "@backstage/cli": "workspace:^",
+ "@types/supertest": "^2.0.8",
+ "msw": "^1.0.0",
+ "supertest": "^6.2.4"
+ },
+ "files": [
+ "dist"
+ ]
+}
diff --git a/plugins/nomad-backend/src/index.ts b/plugins/nomad-backend/src/index.ts
new file mode 100644
index 0000000000..d2e8d61bad
--- /dev/null
+++ b/plugins/nomad-backend/src/index.ts
@@ -0,0 +1,16 @@
+/*
+ * 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 * from './service/router';
diff --git a/plugins/nomad-backend/src/run.ts b/plugins/nomad-backend/src/run.ts
new file mode 100644
index 0000000000..711bdcc46c
--- /dev/null
+++ b/plugins/nomad-backend/src/run.ts
@@ -0,0 +1,34 @@
+/*
+ * 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 { getRootLogger } from '@backstage/backend-common';
+import yn from 'yn';
+import { startStandaloneServer } from './service/standaloneServer';
+import { ConfigReader } from '@backstage/config';
+
+const config = new ConfigReader({});
+const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007;
+const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
+const logger = getRootLogger();
+
+startStandaloneServer({ config, port, enableCors, logger }).catch(err => {
+ logger.error(err);
+ process.exit(1);
+});
+
+process.on('SIGINT', () => {
+ logger.info('CTRL+C pressed; exiting.');
+ process.exit(0);
+});
diff --git a/plugins/nomad-backend/src/service/router.test.ts b/plugins/nomad-backend/src/service/router.test.ts
new file mode 100644
index 0000000000..11c3834ed6
--- /dev/null
+++ b/plugins/nomad-backend/src/service/router.test.ts
@@ -0,0 +1,51 @@
+/*
+ * 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 { getVoidLogger } from '@backstage/backend-common';
+import express from 'express';
+import request from 'supertest';
+
+import { createRouter } from './router';
+import { ConfigReader } from '@backstage/config';
+
+describe('createRouter', () => {
+ let app: express.Express;
+
+ beforeAll(async () => {
+ const router = await createRouter({
+ config: new ConfigReader({
+ nomad: {
+ addr: 'http://localhost.com',
+ token: 'asdf',
+ },
+ }),
+ logger: getVoidLogger(),
+ });
+ app = express().use(router);
+ });
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ describe('GET /health', () => {
+ it('returns ok', async () => {
+ const response = await request(app).get('/health');
+
+ expect(response.status).toEqual(200);
+ expect(response.body).toEqual({ status: 'ok' });
+ });
+ });
+});
diff --git a/plugins/nomad-backend/src/service/router.ts b/plugins/nomad-backend/src/service/router.ts
new file mode 100644
index 0000000000..f9355b27d0
--- /dev/null
+++ b/plugins/nomad-backend/src/service/router.ts
@@ -0,0 +1,128 @@
+/*
+ * 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 fetch from 'node-fetch';
+import { errorHandler, requestLoggingHandler } from '@backstage/backend-common';
+import { Config } from '@backstage/config';
+import { InputError } from '@backstage/errors';
+import express from 'express';
+import Router from 'express-promise-router';
+import { Logger } from 'winston';
+
+/** @public */
+export interface RouterOptions {
+ logger: Logger;
+ config: Config;
+}
+
+/** @public */
+export async function createRouter(
+ options: RouterOptions,
+): Promise {
+ const { config, logger } = options;
+
+ // Get Nomad addr and token from config
+ const addr = config.getString('nomad.addr');
+ const token = config.getOptionalString('nomad.token');
+
+ const router = Router();
+ router.use(express.json());
+
+ router.get('/health', (_, resp) => {
+ resp.json({ status: 'ok' });
+ });
+
+ router.get('/v1/allocations', async (req, res) => {
+ // 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) ?? '';
+ logger.debug(`request headers: namespace=${namespace} filter=${filter}`);
+
+ // Issue the request
+ const endpoint = `${addr}/v1/allocations?namespace=${encodeURIComponent(
+ namespace,
+ )}&filter=${encodeURIComponent(filter)}`;
+ const allocationsResp = await fetch(endpoint, {
+ method: 'GET',
+ headers: {
+ Accept: 'application/json',
+ 'X-Nomad-Token': token || '',
+ },
+ });
+
+ // Check response
+ if (allocationsResp.status !== 200) {
+ const body = await allocationsResp.text();
+ logger.error(`failed to call /v1/allocations endpoint: ${body}`);
+ res.status(allocationsResp.status).json({ message: body });
+ return;
+ }
+
+ // Deserialize and return
+ const allocationsBody = await allocationsResp.json();
+ logger.debug(`/v1/allocations response: ${allocationsBody}`);
+ res.json(allocationsBody);
+ });
+
+ router.get('/v1/job/:job_id/versions', async (req, resp) => {
+ // Check namespace argument
+ const namespace = (req.query.namespace as string) ?? '';
+ if (!namespace || namespace === '') {
+ throw new InputError(`Missing "namespace" query parameter`);
+ }
+
+ // Get job ID
+ const jobID = (req.params.job_id as string) ?? '';
+
+ logger.info(`token: ${token}`);
+
+ // Issue the request
+ const apiResp = await fetch(
+ `${addr}/v1/job/${jobID}/versions?namespace=${encodeURIComponent(
+ namespace,
+ )}`,
+ {
+ method: 'GET',
+ headers: {
+ Accept: 'application/json',
+ 'X-Nomad-Token': token || '',
+ },
+ },
+ );
+
+ // Check response
+ if (apiResp.status !== 200) {
+ const body = await apiResp.text();
+ logger.error(`failed to call /v1/job/:job_id/versions endpoint: ${body}`);
+ resp.status(apiResp.status).json({ message: body });
+ return;
+ }
+
+ // Deserialize and return
+ const versionsBody = await apiResp.json();
+ logger.debug(`/v1/job/:job_id/versions response: ${versionsBody}`);
+ resp.json(versionsBody);
+ });
+
+ router.use(requestLoggingHandler());
+ router.use(errorHandler());
+ return router;
+}
diff --git a/plugins/nomad-backend/src/service/standaloneServer.ts b/plugins/nomad-backend/src/service/standaloneServer.ts
new file mode 100644
index 0000000000..133577ffc2
--- /dev/null
+++ b/plugins/nomad-backend/src/service/standaloneServer.ts
@@ -0,0 +1,52 @@
+/*
+ * 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 { createServiceBuilder } from '@backstage/backend-common';
+import { Server } from 'http';
+import { Logger } from 'winston';
+import { createRouter } from './router';
+import { Config } from '@backstage/config';
+
+export interface ServerOptions {
+ config: Config;
+ enableCors: boolean;
+ logger: Logger;
+ port: number;
+}
+
+export async function startStandaloneServer(
+ options: ServerOptions,
+): Promise {
+ const logger = options.logger.child({ service: 'nomad' });
+ logger.debug('Starting application server...');
+ const router = await createRouter({
+ logger,
+ config: options.config,
+ });
+
+ let service = createServiceBuilder(module)
+ .setPort(options.port)
+ .addRouter('/nomad', router);
+ if (options.enableCors) {
+ service = service.enableCors({ origin: 'http://localhost:3000' });
+ }
+
+ return await service.start().catch(err => {
+ logger.error(err);
+ process.exit(1);
+ });
+}
+
+module.hot?.accept();
diff --git a/plugins/nomad-backend/src/setupTests.ts b/plugins/nomad-backend/src/setupTests.ts
new file mode 100644
index 0000000000..4b9026cde5
--- /dev/null
+++ b/plugins/nomad-backend/src/setupTests.ts
@@ -0,0 +1,16 @@
+/*
+ * 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 {};
diff --git a/plugins/nomad/.eslintrc.js b/plugins/nomad/.eslintrc.js
new file mode 100644
index 0000000000..e2a53a6ad2
--- /dev/null
+++ b/plugins/nomad/.eslintrc.js
@@ -0,0 +1 @@
+module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
diff --git a/plugins/nomad/README.md b/plugins/nomad/README.md
new file mode 100644
index 0000000000..da14a0a3f3
--- /dev/null
+++ b/plugins/nomad/README.md
@@ -0,0 +1,167 @@
+# @backstage/plugin-nomad
+
+This is a frontend plugin is for viewing [Nomad](https://www.nomadproject.io/) [job versions](https://developer.hashicorp.com/nomad/docs/concepts/architecture#job) and [task group allocations](https://developer.hashicorp.com/nomad/docs/concepts/architecture#allocation).
+
+This plugin has a corresponding backend plugin required to call the Nomad cluster's API: ` @backstage/plugin-nomad-backend`.
+
+## Introduction
+
+### [Nomad](https://www.nomadproject.io/)
+
+> A simple and flexible scheduler and orchestrator to deploy and manage containers and non-containerized applications across on-prem and clouds at scale.
+
+### Features
+
+This plugin provides two components:
+
+- a table to view recent [job versions](https://developer.hashicorp.com/nomad/docs/commands/job/history)
+- a table to view [allocations for a job and/or group](https://developer.hashicorp.com/nomad/tutorials/manage-jobs/jobs-inspect)
+
+## Getting Started
+
+### Requirements
+
+You will need to have the backend Nomad plugin, `@backstage/plugin-nomad-backend`, installed and running. See its README for set up instructions.
+
+You will need a running Nomad cluster with an API address that is reachable from the `@backstage/plugin-nomad/backend` plugin [running in the back end](https://backstage.io/docs/overview/architecture-overview/#third-party-backed-plugins). You can follow [this tutorial](https://developer.hashicorp.com/nomad/tutorials/enterprise/production-deployment-guide-vm-with-consul) to learn how to deploy one.
+
+If your Nomad cluster has ACLs enabled, you will need a `token` with at least the [`list-jobs`capability](https://developer.hashicorp.com/nomad/tutorials/access-control/access-control-policies#namespace-rules). You can check [this tutorial](https://developer.hashicorp.com/nomad/tutorials/access-control/access-control-create-policy) for more info or the minimal [example below](#example-policy).
+
+### Installation
+
+```bash
+# From your Backstage root directory
+yarn add --cwd packages/app @backstage/plugin-nomad
+```
+
+### Configuration
+
+Add configuration to your [`app-config.yaml`](https://github.com/backstage/backstage/blob/master/app-config.yaml). For example:
+
+```yaml
+nomad:
+ addr: 'http://localhost:4646'
+ token: '93e034ad-e504-42f9-129d-5d81be9f13d3'
+```
+
+The `token` can be excluded if [ACLs are not enabled](https://developer.hashicorp.com/nomad/api-docs#authentication).
+
+### Annotate Components
+
+Several annotations are available for Components that make use of this plugin:
+
+```yaml
+apiVersion: backstage.io/v1alpha1
+kind: Component
+metadata:
+ annotations:
+ nomad.io/namespace: default
+ nomad.io/job-id: redis
+ nomad.io/group: 'redis|prometheus-collector'
+```
+
+- `nomad.io/job-id` annotation's value is matched exactly and corresponds to `JobID` in the Nomad API. It is required for the Job Versions Card but optional for the Allocations Table
+- `nomad.io/group` annotation's value is used as a regex pattern against `TaskGroup` using [Nomad's server-side filtering](https://developer.hashicorp.com/nomad/api-docs#filtering). It is optional for the [Allocations Table](#allocations-table)
+- `nomad.io/namespace` is the Namespace of the Job and Allocations of the Component. If omitted, it defaults to `default`
+
+### Job Versions Card
+
+The snippet below adds a card to the overview tab on the EntityPage. It shows versions of a Nomad job associated with a Component in the Catalog.
+
+```typescript
+// In packages/app/src/components/catalog/EntityPage.tsx
+
+import { EntityNomadJobVersionListCard, isNomadJobIDAvailable } from '@backstage/plugin-nomad';
+
+const overviewContent = (
+ ...
+
+
+
+
+
+
+
+);
+```
+
+
+
+
+
+#### Requirements
+
+- the `nomad.io/job-id` annotation must be set
+
+### Allocations Table
+
+The snippet below adds a `/nomad` tab to the EntityPage that displays all allocations associated the `nomad.io/job-id` and/or `nomad.io/group` of Component's annotations.
+
+```typescript
+// In packages/app/src/components/catalog/EntityPage.tsx
+
+import { EntityNomadAllocationListTable, isNomadAllocationsAvailable } from '@backstage/plugin-nomad';
+
+const serviceEntityPage = (
+ ...
+
+
+
+)
+```
+
+
+
+
+
+#### Requirements
+
+- `nomad.io/job-id` and/or `nomad.io/group` annotations must be set
+
+## ACL Policy Example
+
+Because this plugin uses API endpoints that require the `list-jobs` capability, the token you provide to the plugin's [`nomad` configuration](#configuration) needs at least that.
+
+To create such a token you can create a policy like below. This policy applies to all namespaces:
+
+```hcl
+# backstage.policy.hcl
+namespace "*" {
+ policy = "read"
+}
+
+node {
+ policy = "read"
+}
+```
+
+And create a policy for it:
+
+```bash
+nomad acl policy apply backstage backstage.policy.hcl
+```
+
+Then create a client token for it:
+
+```bash
+nomad acl token create -name=backstage -policy=backstage
+Accessor ID = 5e9fe97b-76c5-8803-21b8-083308dc6c11
+Secret ID = 93e034ad-e504-42f9-129d-5d81be9f13d3
+Name = backstage
+Type = client
+Global = false
+Create Time = 2023-06-05 00:45:20.51905 +0000 UTC
+Expiry Time =
+Create Index = 54
+Modify Index = 54
+Policies = [backstage]
+
+Roles
+
+```
+
+In the example above, the `Secret ID` is the `token` to use in the [configuration](#configuration).
diff --git a/plugins/nomad/api-report.md b/plugins/nomad/api-report.md
new file mode 100644
index 0000000000..f000854374
--- /dev/null
+++ b/plugins/nomad/api-report.md
@@ -0,0 +1,35 @@
+## API Report File for "@backstage/plugin-nomad"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+///
+
+import { BackstagePlugin } from '@backstage/core-plugin-api';
+import { Entity } from '@backstage/catalog-model';
+import { RouteRef } from '@backstage/core-plugin-api';
+
+// @public
+export const EntityNomadAllocationListTable: () => JSX.Element;
+
+// @public
+export const EntityNomadJobVersionListCard: () => JSX.Element | null;
+
+// @public (undocumented)
+export const isNomadAllocationsAvailable: (entity: Entity) => boolean;
+
+// @public (undocumented)
+export const isNomadJobIDAvailable: (entity: Entity) => boolean;
+
+// @public (undocumented)
+export const nomadPlugin: BackstagePlugin<
+ {
+ root: RouteRef;
+ entityContent: RouteRef;
+ },
+ {},
+ {}
+>;
+
+// (No @packageDocumentation comment for this package)
+```
diff --git a/plugins/nomad/dev/index.tsx b/plugins/nomad/dev/index.tsx
new file mode 100644
index 0000000000..73eaffb8e0
--- /dev/null
+++ b/plugins/nomad/dev/index.tsx
@@ -0,0 +1,19 @@
+/*
+ * 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 { createDevApp } from '@backstage/dev-utils';
+import { nomadPlugin } from '../src/plugin';
+
+createDevApp().registerPlugin(nomadPlugin).render();
diff --git a/plugins/nomad/img/allocations-component.png b/plugins/nomad/img/allocations-component.png
new file mode 100644
index 0000000000..fb1e9768af
Binary files /dev/null and b/plugins/nomad/img/allocations-component.png differ
diff --git a/plugins/nomad/img/job-versions-component.png b/plugins/nomad/img/job-versions-component.png
new file mode 100644
index 0000000000..d6c9746bcb
Binary files /dev/null and b/plugins/nomad/img/job-versions-component.png differ
diff --git a/plugins/nomad/package.json b/plugins/nomad/package.json
new file mode 100644
index 0000000000..7501aabf31
--- /dev/null
+++ b/plugins/nomad/package.json
@@ -0,0 +1,81 @@
+{
+ "name": "@backstage/plugin-nomad",
+ "version": "0.0.0",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "license": "Apache-2.0",
+ "publishConfig": {
+ "access": "public",
+ "main": "dist/index.esm.js",
+ "types": "dist/index.d.ts"
+ },
+ "backstage": {
+ "role": "frontend-plugin"
+ },
+ "scripts": {
+ "start": "backstage-cli package start",
+ "build": "backstage-cli package build",
+ "lint": "backstage-cli package lint",
+ "test": "backstage-cli package test",
+ "clean": "backstage-cli package clean",
+ "prepack": "backstage-cli package prepack",
+ "postpack": "backstage-cli package postpack"
+ },
+ "dependencies": {
+ "@backstage/catalog-model": "workspace:^",
+ "@backstage/config": "workspace:^",
+ "@backstage/core-components": "workspace:^",
+ "@backstage/core-plugin-api": "workspace:^",
+ "@backstage/plugin-catalog-react": "workspace:^",
+ "@backstage/theme": "workspace:^",
+ "@material-ui/core": "^4.9.13",
+ "@material-ui/icons": "^4.9.1",
+ "@material-ui/lab": "^4.0.0-alpha.60",
+ "luxon": "^3.3.0",
+ "react-use": "^17.2.4"
+ },
+ "peerDependencies": {
+ "react": "^16.13.1 || ^17.0.0",
+ "react-router-dom": "*"
+ },
+ "devDependencies": {
+ "@backstage/cli": "workspace:^",
+ "@backstage/core-app-api": "workspace:^",
+ "@backstage/dev-utils": "workspace:^",
+ "@backstage/test-utils": "workspace:^",
+ "@testing-library/jest-dom": "^5.10.1",
+ "@testing-library/react": "^12.1.3",
+ "@testing-library/user-event": "^14.0.0",
+ "@types/node": "*",
+ "cross-fetch": "^3.1.5",
+ "msw": "^1.0.0"
+ },
+ "files": [
+ "dist"
+ ],
+ "configSchema": {
+ "$schema": "https://backstage.io/schema/config-v1",
+ "title": "@backstage/plugin-nomad",
+ "type": "object",
+ "properties": {
+ "nomad": {
+ "type": "object",
+ "properties": {
+ "addr": {
+ "type": "string",
+ "description": "The address of the Nomad API. See: https://developer.hashicorp.com/nomad/api-docs#addressing-and-ports",
+ "visibility": "frontend"
+ },
+ "token": {
+ "type": "string",
+ "description": "The token to call the Nomad API with. See: https://developer.hashicorp.com/nomad/api-docs#authentication",
+ "visibility": "secret"
+ }
+ },
+ "required": [
+ "addr"
+ ]
+ }
+ }
+ }
+}
diff --git a/plugins/nomad/src/Router.tsx b/plugins/nomad/src/Router.tsx
new file mode 100644
index 0000000000..d7e6db1b44
--- /dev/null
+++ b/plugins/nomad/src/Router.tsx
@@ -0,0 +1,58 @@
+/*
+ * 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 React from 'react';
+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 { EntityNomadAllocationListTable } from './components/EntityNomadAllocationListTable/EntityNomadAllocationListTable';
+
+/** @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 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]);
+
+export const EmbeddedRouter = () => {
+ const { entity } = useEntity();
+
+ if (!isNomadAllocationsAvailable(entity)) {
+ return (
+
+ );
+ }
+
+ return (
+
+ } />
+
+ );
+};
diff --git a/plugins/nomad/src/api.ts b/plugins/nomad/src/api.ts
new file mode 100644
index 0000000000..44aa5d48d2
--- /dev/null
+++ b/plugins/nomad/src/api.ts
@@ -0,0 +1,160 @@
+/*
+ * 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 {
+ DiscoveryApi,
+ FetchApi,
+ createApiRef,
+} from '@backstage/core-plugin-api';
+
+/** @public */
+export const nomadApiRef = createApiRef({
+ id: 'plugin.nomad.service',
+});
+
+/** @public */
+export type NomadApi = {
+ /**
+ * listAllocations is for listing all allocations matching some part of 'filter'.
+ *
+ * See: https://developer.hashicorp.com/nomad/api-docs/allocations#list-allocations
+ */
+ listAllocations: (
+ options: ListAllocationsRequest,
+ ) => Promise;
+
+ /**
+ * listJobVersions is for listing all deployments matching some part of 'filter'.
+ *
+ * See: https://developer.hashicorp.com/nomad/api-docs/deployments#list-deployments
+ */
+ listJobVersions: (
+ options: ListJobVersionsRequest,
+ ) => Promise;
+};
+
+/** @public */
+export interface ListAllocationsRequest {
+ namespace: string;
+ filter: string;
+}
+
+/** @public */
+export interface ListAllocationsResponse {
+ allocations: Allocation[];
+}
+
+/** @public */
+export interface Allocation {
+ ClientStatus: string;
+ CreateTime: number;
+ DeploymentStatus: DeploymentStatus;
+ ID: string;
+ JobID: string;
+ JobVersion: string;
+ ModifyTime: number;
+ Name: string;
+ Namespace: string;
+ NodeID: string;
+ TaskGroup: string;
+}
+
+/** @public */
+export interface DeploymentStatus {
+ Healthy: boolean;
+ Timestamp: string;
+}
+
+/** @public */
+export interface ListJobVersionsRequest {
+ namespace: string;
+ jobID: string;
+}
+
+/** @public */
+export interface ListJobVersionsResponse {
+ versions: Version[];
+}
+
+/** @public */
+export interface Version {
+ ID: string;
+ SubmitTime: number;
+ Stable: boolean;
+ Version: number;
+}
+
+/** @public */
+export class FetchError extends Error {
+ get name(): string {
+ return this.constructor.name;
+ }
+
+ static async forResponse(resp: Response): Promise {
+ return new FetchError(
+ `Request failed with status code ${
+ resp.status
+ }.\nReason: ${await resp.text()}`,
+ );
+ }
+}
+
+/** @public */
+export class NomadHttpApi implements NomadApi {
+ static create(discoveryApi: DiscoveryApi, fetchApi: FetchApi) {
+ return new NomadHttpApi(discoveryApi, fetchApi);
+ }
+
+ constructor(private discoveryApi: DiscoveryApi, private fetchApi: FetchApi) {}
+
+ // TODO: pagination
+ async listAllocations(
+ options: ListAllocationsRequest,
+ ): Promise {
+ const apiUrl = await this.discoveryApi.getBaseUrl('nomad');
+
+ const resp = await this.fetchApi.fetch(
+ `${apiUrl}/v1/allocations?namespace=${encodeURIComponent(
+ options.namespace,
+ )}&filter=${encodeURIComponent(options.filter)}`,
+ );
+ if (!resp.ok) throw await FetchError.forResponse(resp);
+
+ return {
+ allocations: await resp.json(),
+ };
+ }
+
+ // TODO: pagination
+ async listJobVersions(
+ options: ListJobVersionsRequest,
+ ): Promise {
+ const apiUrl = await this.discoveryApi.getBaseUrl('nomad');
+
+ const resp = await this.fetchApi.fetch(
+ `${apiUrl}/v1/job/${
+ options.jobID
+ }/versions?namespace=${encodeURIComponent(options.namespace)}`,
+ );
+ if (!resp.ok) throw await FetchError.forResponse(resp);
+
+ const respJson = await resp.json();
+
+ return {
+ versions: respJson.Versions,
+ };
+ }
+}
diff --git a/plugins/nomad/src/components/EntityNomadAllocationListTable/EntityNomadAllocationListTable.tsx b/plugins/nomad/src/components/EntityNomadAllocationListTable/EntityNomadAllocationListTable.tsx
new file mode 100644
index 0000000000..6d7a79c002
--- /dev/null
+++ b/plugins/nomad/src/components/EntityNomadAllocationListTable/EntityNomadAllocationListTable.tsx
@@ -0,0 +1,201 @@
+/*
+ * 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 { DateTime } from 'luxon';
+import {
+ Link,
+ MissingAnnotationEmptyState,
+ 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,
+ isNomadAllocationsAvailable,
+} 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 =>
+ DateTime.fromMillis(row.CreateTime / 1000000).toLocaleString(
+ DateTime.DATETIME_MED_WITH_SECONDS,
+ ),
+ },
+ {
+ 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]}
+
+ ),
+ },
+];
+
+/**
+ * EntityNomadAllocationListTable is roughly based off Nomad's Allocations tab's view.
+ */
+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');
+
+ // Store results of calling API
+ const [allocations, setAllocations] = useState([]);
+ const [err, setErr] = useState();
+
+ // Check that attributes are available
+ if (!isNomadAllocationsAvailable(entity)) {
+ ;
+ }
+
+ // 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 == "${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;
+ }
+ return 0;
+ });
+
+ setAllocations(results.map(row => ({ ...row, id: row.ID, nomadAddr })));
+ setErr(undefined);
+ } catch (e) {
+ setAllocations([]);
+ setErr(e);
+ }
+ };
+
+ // Start querying for allocations every 5s
+ useAsync(async () => {
+ if (init) {
+ setInit(false);
+ query();
+ }
+
+ const interval = setTimeout(() => {
+ query();
+ }, 5_000);
+
+ return () => clearTimeout(interval);
+ }, [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/EntityNomadJobVersionListCard/EntityNomadJobVersionListCard.tsx b/plugins/nomad/src/components/EntityNomadJobVersionListCard/EntityNomadJobVersionListCard.tsx
new file mode 100644
index 0000000000..0c1863b95e
--- /dev/null
+++ b/plugins/nomad/src/components/EntityNomadJobVersionListCard/EntityNomadJobVersionListCard.tsx
@@ -0,0 +1,152 @@
+/*
+ * 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 { DateTime } from 'luxon';
+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[] = [
+ {
+ title: 'Version',
+ field: 'Version',
+ render: row => row.Version,
+ },
+ {
+ title: 'Stable',
+ field: 'Stable',
+ render: row => ,
+ },
+ {
+ title: 'Submitted',
+ field: 'SubmitTime',
+ render: row =>
+ DateTime.fromMillis(row.SubmitTime / 1000000).toLocaleString(
+ DateTime.DATETIME_MED_WITH_SECONDS,
+ ),
+ },
+];
+
+/**
+ * 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([]);
+ const [err, setErr] = useState();
+
+ // 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 ;
+ }
+
+ if (!entity) return null;
+
+ // Check that job ID is set
+ if (!isNomadJobIDAvailable(entity)) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ title="Job versions"
+ actions={[
+ {
+ icon: () => ,
+ tooltip: 'Open in Nomad UI',
+ 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}
+ />
+ );
+};
diff --git a/plugins/nomad/src/components/index.ts b/plugins/nomad/src/components/index.ts
new file mode 100644
index 0000000000..3951061b23
--- /dev/null
+++ b/plugins/nomad/src/components/index.ts
@@ -0,0 +1,19 @@
+/*
+ * 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 { EntityNomadJobVersionListCard } from './EntityNomadJobVersionListCard/EntityNomadJobVersionListCard';
+import { EntityNomadAllocationListTable } from './EntityNomadAllocationListTable/EntityNomadAllocationListTable';
+
+export { EntityNomadJobVersionListCard, EntityNomadAllocationListTable };
diff --git a/plugins/nomad/src/index.ts b/plugins/nomad/src/index.ts
new file mode 100644
index 0000000000..3785a5c7b5
--- /dev/null
+++ b/plugins/nomad/src/index.ts
@@ -0,0 +1,21 @@
+/*
+ * 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 {
+ nomadPlugin,
+ EntityNomadJobVersionListCard,
+ EntityNomadAllocationListTable,
+} from './plugin';
+export { isNomadAllocationsAvailable, isNomadJobIDAvailable } from './Router';
diff --git a/plugins/nomad/src/plugin.test.ts b/plugins/nomad/src/plugin.test.ts
new file mode 100644
index 0000000000..def0f8ab89
--- /dev/null
+++ b/plugins/nomad/src/plugin.test.ts
@@ -0,0 +1,34 @@
+/*
+ * 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 {
+ nomadPlugin,
+ entityContentRouteRef,
+ EntityNomadContent,
+} from './plugin';
+
+describe('nomad', () => {
+ it('should export plugin', () => {
+ expect(nomadPlugin).toBeDefined();
+ });
+
+ it('should export Nomad entity ref', () => {
+ expect(entityContentRouteRef).toBeDefined();
+ });
+
+ it('should export Nomad entity', () => {
+ expect(EntityNomadContent).toBeDefined();
+ });
+});
diff --git a/plugins/nomad/src/plugin.ts b/plugins/nomad/src/plugin.ts
new file mode 100644
index 0000000000..df89785941
--- /dev/null
+++ b/plugins/nomad/src/plugin.ts
@@ -0,0 +1,87 @@
+/*
+ * 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 {
+ discoveryApiRef,
+ fetchApiRef,
+ createApiFactory,
+ createPlugin,
+ createRouteRef,
+ createRoutableExtension,
+ createComponentExtension,
+} from '@backstage/core-plugin-api';
+
+import { rootRouteRef } from './routes';
+import { NomadHttpApi, nomadApiRef } from './api';
+
+export const entityContentRouteRef = createRouteRef({
+ id: 'nomad:entity-content',
+});
+
+/** @public */
+export const nomadPlugin = createPlugin({
+ id: 'nomad',
+ apis: [
+ createApiFactory({
+ api: nomadApiRef,
+ deps: { discoveryApi: discoveryApiRef, fetchApi: fetchApiRef },
+ factory: ({ discoveryApi, fetchApi }) =>
+ NomadHttpApi.create(discoveryApi, fetchApi),
+ }),
+ ],
+ routes: {
+ root: rootRouteRef,
+ entityContent: entityContentRouteRef,
+ },
+});
+
+/** @public */
+export const EntityNomadContent = nomadPlugin.provide(
+ createRoutableExtension({
+ name: 'EntityNomadContent',
+ component: () => import('./Router').then(m => m.EmbeddedRouter),
+ mountPoint: entityContentRouteRef,
+ }),
+);
+
+/**
+ * Card used to show the list of Nomad job versions.
+ *
+ * @public
+ */
+export const EntityNomadJobVersionListCard = nomadPlugin.provide(
+ createComponentExtension({
+ name: 'EntityNomadJobVersionListCard',
+ component: {
+ lazy: () =>
+ import('./components').then(m => m.EntityNomadJobVersionListCard),
+ },
+ }),
+);
+
+/**
+ * Table used to show the list of Nomad allocations for a job and/or task-group.
+ *
+ * @public
+ */
+export const EntityNomadAllocationListTable = nomadPlugin.provide(
+ createComponentExtension({
+ name: 'EntityNomadAllocationListTable',
+ component: {
+ lazy: () =>
+ import('./components').then(m => m.EntityNomadAllocationListTable),
+ },
+ }),
+);
diff --git a/plugins/nomad/src/routes.ts b/plugins/nomad/src/routes.ts
new file mode 100644
index 0000000000..12e8caf4eb
--- /dev/null
+++ b/plugins/nomad/src/routes.ts
@@ -0,0 +1,20 @@
+/*
+ * 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 { createRouteRef } from '@backstage/core-plugin-api';
+
+export const rootRouteRef = createRouteRef({
+ id: 'nomad',
+});
diff --git a/plugins/nomad/src/setupTests.ts b/plugins/nomad/src/setupTests.ts
new file mode 100644
index 0000000000..73dd8dce47
--- /dev/null
+++ b/plugins/nomad/src/setupTests.ts
@@ -0,0 +1,17 @@
+/*
+ * 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 '@testing-library/jest-dom';
+import 'cross-fetch/polyfill';
diff --git a/yarn.lock b/yarn.lock
index d0b99c2b28..3bda8a89ae 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7939,6 +7939,57 @@ __metadata:
languageName: unknown
linkType: soft
+"@backstage/plugin-nomad-backend@workspace:^, @backstage/plugin-nomad-backend@workspace:plugins/nomad-backend":
+ version: 0.0.0-use.local
+ resolution: "@backstage/plugin-nomad-backend@workspace:plugins/nomad-backend"
+ dependencies:
+ "@backstage/backend-common": "workspace:^"
+ "@backstage/cli": "workspace:^"
+ "@backstage/config": "workspace:^"
+ "@backstage/errors": "workspace:^"
+ "@types/express": "*"
+ "@types/supertest": ^2.0.8
+ express: ^4.17.1
+ express-promise-router: ^4.1.0
+ msw: ^1.0.0
+ node-fetch: ^2.6.7
+ supertest: ^6.2.4
+ winston: ^3.2.1
+ yn: ^4.0.0
+ languageName: unknown
+ linkType: soft
+
+"@backstage/plugin-nomad@workspace:^, @backstage/plugin-nomad@workspace:plugins/nomad":
+ version: 0.0.0-use.local
+ resolution: "@backstage/plugin-nomad@workspace:plugins/nomad"
+ dependencies:
+ "@backstage/catalog-model": "workspace:^"
+ "@backstage/cli": "workspace:^"
+ "@backstage/config": "workspace:^"
+ "@backstage/core-app-api": "workspace:^"
+ "@backstage/core-components": "workspace:^"
+ "@backstage/core-plugin-api": "workspace:^"
+ "@backstage/dev-utils": "workspace:^"
+ "@backstage/plugin-catalog-react": "workspace:^"
+ "@backstage/test-utils": "workspace:^"
+ "@backstage/theme": "workspace:^"
+ "@material-ui/core": ^4.9.13
+ "@material-ui/icons": ^4.9.1
+ "@material-ui/lab": ^4.0.0-alpha.60
+ "@testing-library/jest-dom": ^5.10.1
+ "@testing-library/react": ^12.1.3
+ "@testing-library/user-event": ^14.0.0
+ "@types/node": "*"
+ cross-fetch: ^3.1.5
+ luxon: ^3.3.0
+ msw: ^1.0.0
+ react-use: ^17.2.4
+ peerDependencies:
+ react: ^16.13.1 || ^17.0.0
+ react-router-dom: "*"
+ languageName: unknown
+ linkType: soft
+
"@backstage/plugin-octopus-deploy@workspace:^, @backstage/plugin-octopus-deploy@workspace:plugins/octopus-deploy":
version: 0.0.0-use.local
resolution: "@backstage/plugin-octopus-deploy@workspace:plugins/octopus-deploy"
@@ -24577,6 +24628,7 @@ __metadata:
"@backstage/plugin-microsoft-calendar": "workspace:^"
"@backstage/plugin-newrelic": "workspace:^"
"@backstage/plugin-newrelic-dashboard": "workspace:^"
+ "@backstage/plugin-nomad": "workspace:^"
"@backstage/plugin-octopus-deploy": "workspace:^"
"@backstage/plugin-org": "workspace:^"
"@backstage/plugin-pagerduty": "workspace:^"
@@ -24695,6 +24747,7 @@ __metadata:
"@backstage/plugin-kubernetes-backend": "workspace:^"
"@backstage/plugin-lighthouse-backend": "workspace:^"
"@backstage/plugin-linguist-backend": "workspace:^"
+ "@backstage/plugin-nomad-backend": "workspace:^"
"@backstage/plugin-permission-backend": "workspace:^"
"@backstage/plugin-permission-common": "workspace:^"
"@backstage/plugin-permission-node": "workspace:^"