From ca7a874fa2456385ddd31ec0314e094e97bdc619 Mon Sep 17 00:00:00 2001 From: josh Date: Sun, 4 Jun 2023 15:33:03 -0400 Subject: [PATCH 01/34] Init Nomad Signed-off-by: josh --- .github/CODEOWNERS | 161 +++++++++--------- packages/app/package.json | 1 + packages/app/src/App.tsx | 2 + plugins/nomad/.eslintrc.js | 1 + plugins/nomad/README.md | 13 ++ plugins/nomad/dev/index.tsx | 27 +++ plugins/nomad/package.json | 51 ++++++ .../ExampleComponent.test.tsx | 42 +++++ .../ExampleComponent/ExampleComponent.tsx | 53 ++++++ .../src/components/ExampleComponent/index.ts | 16 ++ .../ExampleFetchComponent.test.tsx | 40 +++++ .../ExampleFetchComponent.tsx | 111 ++++++++++++ .../components/ExampleFetchComponent/index.ts | 16 ++ plugins/nomad/src/index.ts | 16 ++ plugins/nomad/src/plugin.test.ts | 22 +++ plugins/nomad/src/plugin.ts | 37 ++++ plugins/nomad/src/routes.ts | 20 +++ plugins/nomad/src/setupTests.ts | 17 ++ yarn.lock | 27 +++ 19 files changed, 591 insertions(+), 82 deletions(-) create mode 100644 plugins/nomad/.eslintrc.js create mode 100644 plugins/nomad/README.md create mode 100644 plugins/nomad/dev/index.tsx create mode 100644 plugins/nomad/package.json create mode 100644 plugins/nomad/src/components/ExampleComponent/ExampleComponent.test.tsx create mode 100644 plugins/nomad/src/components/ExampleComponent/ExampleComponent.tsx create mode 100644 plugins/nomad/src/components/ExampleComponent/index.ts create mode 100644 plugins/nomad/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx create mode 100644 plugins/nomad/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx create mode 100644 plugins/nomad/src/components/ExampleFetchComponent/index.ts create mode 100644 plugins/nomad/src/index.ts create mode 100644 plugins/nomad/src/plugin.test.ts create mode 100644 plugins/nomad/src/plugin.ts create mode 100644 plugins/nomad/src/routes.ts create mode 100644 plugins/nomad/src/setupTests.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 33097b505a..f7a7f99250 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -4,85 +4,82 @@ # The last matching pattern takes precedence. # https://help.github.com/articles/about-codeowners/ -* @backstage/maintainers -yarn.lock @backstage/maintainers @backstage-service -*/yarn.lock @backstage/maintainers @backstage-service -/.changeset/*.md -/cypress/src/integration/plugins/techdocs.spec.ts @backstage/techdocs-maintainers -/docs/assets/search @backstage/discoverability-maintainers -/docs/features/search @backstage/discoverability-maintainers -/docs/features/techdocs @backstage/techdocs-maintainers -/docs/plugins/integrating-search-into-plugins.md @backstage/discoverability-maintainers -/packages/cli/src/commands/onboard @backstage/sharks -/packages/techdocs-cli @backstage/techdocs-maintainers -/packages/techdocs-cli-embedded-app @backstage/techdocs-maintainers -/plugins/adr @backstage/maintainers @kuangp -/plugins/adr-* @backstage/maintainers @kuangp -/plugins/allure @backstage/maintainers @deepak-bhardwaj-ps -/plugins/apache-airflow @backstage/maintainers @cmpadden -/plugins/api-docs @backstage/maintainers @backstage/sda-se-reviewers -/plugins/azure-devops @backstage/maintainers @marleypowell @awanlin -/plugins/azure-devops-backend @backstage/maintainers @marleypowell @awanlin -/plugins/azure-devops-common @backstage/maintainers @marleypowell @awanlin -/plugins/bitbucket-cloud-common @backstage/maintainers @pjungermann -/plugins/bitrise @backstage/maintainers @backstage/sda-se-reviewers -/plugins/catalog @backstage/maintainers @backstage/catalog-maintainers -/plugins/catalog-* @backstage/maintainers @backstage/catalog-maintainers -/plugins/catalog-backend-module-aws @backstage/maintainers @backstage/catalog-maintainers @pjungermann -/plugins/catalog-backend-module-bitbucket-cloud @backstage/maintainers @backstage/catalog-maintainers @pjungermann -/plugins/catalog-backend-module-msgraph @backstage/maintainers @backstage/catalog-maintainers @pjungermann -/plugins/catalog-backend-module-puppetdb @backstage/maintainers @backstage/catalog-maintainers @tdabasinskas -/plugins/catalog-graph @backstage/maintainers @backstage/catalog-maintainers @backstage/sda-se-reviewers -/plugins/circleci @backstage/maintainers @adamdmharvey -/plugins/cloudbuild @backstage/maintainers @trivago/ebarrios -/plugins/code-coverage @backstage/maintainers @alde @nissayeva -/plugins/code-coverage-backend @backstage/maintainers @alde @nissayeva -/plugins/cost-insights @backstage/maintainers @backstage/silver-lining -/plugins/cost-insights-* @backstage/maintainers @backstage/silver-lining -/plugins/entity-feedback @backstage/maintainers @kuangp -/plugins/entity-feedback-* @backstage/maintainers @kuangp -/plugins/events-backend @backstage/maintainers @pjungermann -/plugins/events-backend-module-aws-sqs @backstage/maintainers @pjungermann -/plugins/events-backend-module-azure @backstage/maintainers @pjungermann -/plugins/events-backend-module-bitbucket-cloud @backstage/maintainers @pjungermann -/plugins/events-backend-module-gerrit @backstage/maintainers @pjungermann -/plugins/events-backend-module-github @backstage/maintainers @pjungermann -/plugins/events-backend-module-gitlab @backstage/maintainers @pjungermann -/plugins/events-backend-test-utils @backstage/maintainers @pjungermann -/plugins/events-node @backstage/maintainers @pjungermann -/plugins/explore @backstage/maintainers @backstage/sda-se-reviewers -/plugins/explore-react @backstage/maintainers @backstage/sda-se-reviewers -/plugins/fossa @backstage/maintainers @backstage/sda-se-reviewers -/plugins/gcalendar @backstage/maintainers @szubster @ptychu @kielosz @alexrybch -/plugins/git-release-manager @backstage/maintainers @erikengervall -/plugins/home @backstage/discoverability-maintainers -/plugins/home-* @backstage/discoverability-maintainers -/plugins/ilert @backstage/maintainers @yacut -/plugins/jenkins @backstage/maintainers @timja -/plugins/jenkins-backend @backstage/maintainers @timja -/plugins/kafka @backstage/maintainers @nirga @andrewthauer -/plugins/kafka-backend @backstage/maintainers @nirga @andrewthauer -/plugins/kubernetes @backstage/maintainers @backstage/kubernetes-maintainers -/plugins/kubernetes-* @backstage/maintainers @backstage/kubernetes-maintainers -/plugins/microsoft-calendar @backstage/maintainers @abhay-soni-developer @NishkarshRaj -/plugins/newrelic-dashboard @backstage/maintainers @mufaddal7 -/plugins/permission-* @backstage/permission-maintainers -/plugins/playlist @backstage/maintainers @kuangp -/plugins/playlist-* @backstage/maintainers @kuangp -/plugins/puppetdb @backstage/maintainers @tdabasinskas -/plugins/rollbar @backstage/maintainers @andrewthauer -/plugins/rollbar-backend @backstage/maintainers @andrewthauer -/plugins/scaffolder-backend-module-rails @backstage/maintainers @angeliski -/plugins/scaffolder-backend-module-yeoman @backstage/maintainers @pawelmitka -/plugins/search @backstage/discoverability-maintainers -/plugins/search-* @backstage/discoverability-maintainers -/plugins/sonarqube @backstage/maintainers @backstage/sda-se-reviewers -/plugins/stack-overflow @backstage/discoverability-maintainers -/plugins/stack-overflow-backend @backstage/discoverability-maintainers -/plugins/techdocs @backstage/techdocs-maintainers -/plugins/techdocs-* @backstage/techdocs-maintainers -/plugins/user-settings-backend @backstage/maintainers @backstage/sda-se-reviewers -/tech-insights-backend @backstage/maintainers @xantier @iain-b -/tech-insights-backend-module-jsonfc @backstage/maintainers @xantier @iain-b -/tech-insights-tech-insights-common @backstage/maintainers @xantier @iain-b -/tech-insights-tech-insights-node @backstage/maintainers @xantier @iain-b +* @backstage/maintainers +*/yarn.lock @backstage/maintainers @backstage-service +/cypress/src/integration/plugins/techdocs.spec.ts @backstage/techdocs-maintainers +/docs/assets/search @backstage/discoverability-maintainers +/docs/features/search @backstage/discoverability-maintainers +/docs/features/techdocs @backstage/techdocs-maintainers +/docs/plugins/integrating-search-into-plugins.md @backstage/discoverability-maintainers +/packages/cli/src/commands/onboard @backstage/sharks +/packages/techdocs-cli @backstage/techdocs-maintainers +/packages/techdocs-cli-embedded-app @backstage/techdocs-maintainers +/plugins/adr @backstage/maintainers @kuangp +/plugins/adr-* @backstage/maintainers @kuangp +/plugins/allure @backstage/maintainers @deepak-bhardwaj-ps +/plugins/apache-airflow @backstage/maintainers @cmpadden +/plugins/api-docs @backstage/maintainers @backstage/sda-se-reviewers +/plugins/azure-devops @backstage/maintainers @marleypowell @awanlin +/plugins/azure-devops-backend @backstage/maintainers @marleypowell @awanlin +/plugins/azure-devops-common @backstage/maintainers @marleypowell @awanlin +/plugins/bitbucket-cloud-common @backstage/maintainers @pjungermann +/plugins/bitrise @backstage/maintainers @backstage/sda-se-reviewers +/plugins/catalog @backstage/maintainers @backstage/catalog-maintainers +/plugins/catalog-* @backstage/maintainers @backstage/catalog-maintainers +/plugins/catalog-backend-module-aws @backstage/maintainers @backstage/catalog-maintainers @pjungermann +/plugins/catalog-backend-module-bitbucket-cloud @backstage/maintainers @backstage/catalog-maintainers @pjungermann +/plugins/catalog-backend-module-msgraph @backstage/maintainers @backstage/catalog-maintainers @pjungermann +/plugins/catalog-backend-module-puppetdb @backstage/maintainers @backstage/catalog-maintainers @tdabasinskas +/plugins/catalog-graph @backstage/maintainers @backstage/catalog-maintainers @backstage/sda-se-reviewers +/plugins/circleci @backstage/maintainers @adamdmharvey +/plugins/cloudbuild @backstage/maintainers @trivago/ebarrios +/plugins/code-coverage @backstage/maintainers @alde @nissayeva +/plugins/code-coverage-backend @backstage/maintainers @alde @nissayeva +/plugins/cost-insights @backstage/maintainers @backstage/silver-lining +/plugins/cost-insights-* @backstage/maintainers @backstage/silver-lining +/plugins/entity-feedback @backstage/maintainers @kuangp +/plugins/entity-feedback-* @backstage/maintainers @kuangp +/plugins/events-backend @backstage/maintainers @pjungermann +/plugins/events-backend-module-aws-sqs @backstage/maintainers @pjungermann +/plugins/events-backend-module-azure @backstage/maintainers @pjungermann +/plugins/events-backend-module-bitbucket-cloud @backstage/maintainers @pjungermann +/plugins/events-backend-module-gerrit @backstage/maintainers @pjungermann +/plugins/events-backend-module-github @backstage/maintainers @pjungermann +/plugins/events-backend-module-gitlab @backstage/maintainers @pjungermann +/plugins/events-backend-test-utils @backstage/maintainers @pjungermann +/plugins/events-node @backstage/maintainers @pjungermann +/plugins/explore @backstage/maintainers @backstage/sda-se-reviewers +/plugins/explore-react @backstage/maintainers @backstage/sda-se-reviewers +/plugins/fossa @backstage/maintainers @backstage/sda-se-reviewers +/plugins/gcalendar @backstage/maintainers @szubster @ptychu @kielosz @alexrybch +/plugins/git-release-manager @backstage/maintainers @erikengervall +/plugins/home @backstage/discoverability-maintainers +/plugins/ilert @backstage/maintainers @yacut +/plugins/jenkins @backstage/maintainers @timja +/plugins/jenkins-backend @backstage/maintainers @timja +/plugins/kafka @backstage/maintainers @nirga @andrewthauer +/plugins/kafka-backend @backstage/maintainers @nirga @andrewthauer +/plugins/kubernetes @backstage/maintainers @backstage/kubernetes-maintainers +/plugins/kubernetes-* @backstage/maintainers @backstage/kubernetes-maintainers +/plugins/microsoft-calendar @backstage/maintainers @abhay-soni-developer @NishkarshRaj +/plugins/newrelic-dashboard @backstage/maintainers @mufaddal7 +/plugins/playlist @backstage/maintainers @kuangp +/plugins/playlist-* @backstage/maintainers @kuangp +/plugins/puppetdb @backstage/maintainers @tdabasinskas +/plugins/rollbar @backstage/maintainers @andrewthauer +/plugins/rollbar-backend @backstage/maintainers @andrewthauer +/plugins/scaffolder-backend-module-rails @backstage/maintainers @angeliski +/plugins/scaffolder-backend-module-yeoman @backstage/maintainers @pawelmitka +/plugins/search @backstage/discoverability-maintainers +/plugins/search-* @backstage/discoverability-maintainers +/plugins/sonarqube @backstage/maintainers @backstage/sda-se-reviewers +/plugins/stack-overflow @backstage/discoverability-maintainers +/plugins/stack-overflow-backend @backstage/discoverability-maintainers +/plugins/techdocs @backstage/techdocs-maintainers +/plugins/techdocs-* @backstage/techdocs-maintainers +/plugins/user-settings-backend @backstage/maintainers @backstage/sda-se-reviewers +/tech-insights-backend @backstage/maintainers @xantier @iain-b +/tech-insights-backend-module-jsonfc @backstage/maintainers @xantier @iain-b +/tech-insights-tech-insights-common @backstage/maintainers @xantier @iain-b +/tech-insights-tech-insights-node @backstage/maintainers @xantier @iain-b +yarn.lock @backstage/maintainers @backstage-service diff --git a/packages/app/package.json b/packages/app/package.json index 7524f62bfa..a41e7f1abf 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": "^0.0.0", "@backstage/plugin-octopus-deploy": "workspace:^", "@backstage/plugin-org": "workspace:^", "@backstage/plugin-pagerduty": "workspace:^", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index df54f37dc6..9a9191afc5 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -110,6 +110,7 @@ import { ScoreBoardPage } from '@oriflame/backstage-plugin-score-card'; import { StackstormPage } from '@backstage/plugin-stackstorm'; import { PuppetDbPage } from '@backstage/plugin-puppetdb'; import { DevToolsPage } from '@backstage/plugin-devtools'; +import { NomadPage } from '@backstage/plugin-nomad'; import { customDevToolsPage } from './components/devtools/CustomDevToolsPage'; import { CatalogUnprocessedEntitiesPage } from '@backstage/plugin-catalog-unprocessed-entities'; @@ -302,6 +303,7 @@ const routes = ( }> {customDevToolsPage} + } /> ); 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..1ec9f3d309 --- /dev/null +++ b/plugins/nomad/README.md @@ -0,0 +1,13 @@ +# nomad + +Welcome to the nomad plugin! + +_This plugin was created through the Backstage CLI_ + +## Getting started + +Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/nomad](http://localhost:3000/nomad). + +You can also serve the plugin in isolation by running `yarn start` in the plugin directory. +This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads. +It is only meant for local development, and the setup for it can be found inside the [/dev](./dev) directory. diff --git a/plugins/nomad/dev/index.tsx b/plugins/nomad/dev/index.tsx new file mode 100644 index 0000000000..07d294c019 --- /dev/null +++ b/plugins/nomad/dev/index.tsx @@ -0,0 +1,27 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { createDevApp } from '@backstage/dev-utils'; +import { nomadPlugin, NomadPage } from '../src/plugin'; + +createDevApp() + .registerPlugin(nomadPlugin) + .addPage({ + element: , + title: 'Root Page', + path: '/nomad', + }) + .render(); diff --git a/plugins/nomad/package.json b/plugins/nomad/package.json new file mode 100644 index 0000000000..17231e7b24 --- /dev/null +++ b/plugins/nomad/package.json @@ -0,0 +1,51 @@ +{ + "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/core-components": "workspace:^", + "@backstage/core-plugin-api": "workspace:^", + "@backstage/theme": "workspace:^", + "@material-ui/core": "^4.9.13", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "^4.0.0-alpha.60", + "react-use": "^17.2.4" + }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, + "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" + ] +} diff --git a/plugins/nomad/src/components/ExampleComponent/ExampleComponent.test.tsx b/plugins/nomad/src/components/ExampleComponent/ExampleComponent.test.tsx new file mode 100644 index 0000000000..b0fb163116 --- /dev/null +++ b/plugins/nomad/src/components/ExampleComponent/ExampleComponent.test.tsx @@ -0,0 +1,42 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { ExampleComponent } from './ExampleComponent'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { screen } from '@testing-library/react'; +import { + setupRequestMockHandlers, + renderInTestApp, +} from '@backstage/test-utils'; + +describe('ExampleComponent', () => { + const server = setupServer(); + // Enable sane handlers for network requests + setupRequestMockHandlers(server); + + // setup mock response + beforeEach(() => { + server.use( + rest.get('/*', (_, res, ctx) => res(ctx.status(200), ctx.json({}))), + ); + }); + + it('should render', async () => { + await renderInTestApp(); + expect(screen.getByText('Welcome to nomad!')).toBeInTheDocument(); + }); +}); diff --git a/plugins/nomad/src/components/ExampleComponent/ExampleComponent.tsx b/plugins/nomad/src/components/ExampleComponent/ExampleComponent.tsx new file mode 100644 index 0000000000..0a0d8809fc --- /dev/null +++ b/plugins/nomad/src/components/ExampleComponent/ExampleComponent.tsx @@ -0,0 +1,53 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { Typography, Grid } from '@material-ui/core'; +import { + InfoCard, + Header, + Page, + Content, + ContentHeader, + HeaderLabel, + SupportButton, +} from '@backstage/core-components'; +import { ExampleFetchComponent } from '../ExampleFetchComponent'; + +export const ExampleComponent = () => ( + +
+ + +
+ + + A description of your plugin goes here. + + + + + + All content should be wrapped in a card like this. + + + + + + + + +
+); diff --git a/plugins/nomad/src/components/ExampleComponent/index.ts b/plugins/nomad/src/components/ExampleComponent/index.ts new file mode 100644 index 0000000000..7e520a7ab8 --- /dev/null +++ b/plugins/nomad/src/components/ExampleComponent/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 { ExampleComponent } from './ExampleComponent'; diff --git a/plugins/nomad/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx b/plugins/nomad/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx new file mode 100644 index 0000000000..a714f640da --- /dev/null +++ b/plugins/nomad/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx @@ -0,0 +1,40 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { ExampleFetchComponent } from './ExampleFetchComponent'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { setupRequestMockHandlers } from '@backstage/test-utils'; + +describe('ExampleFetchComponent', () => { + const server = setupServer(); + // Enable sane handlers for network requests + setupRequestMockHandlers(server); + + // setup mock response + beforeEach(() => { + server.use( + rest.get('https://randomuser.me/*', (_, res, ctx) => + res(ctx.status(200), ctx.delay(2000), ctx.json({})), + ), + ); + }); + it('should render', async () => { + await render(); + expect(await screen.findByTestId('progress')).toBeInTheDocument(); + }); +}); diff --git a/plugins/nomad/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx b/plugins/nomad/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx new file mode 100644 index 0000000000..c3403e1923 --- /dev/null +++ b/plugins/nomad/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx @@ -0,0 +1,111 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { makeStyles } from '@material-ui/core/styles'; +import { + Table, + TableColumn, + Progress, + ResponseErrorPanel, +} from '@backstage/core-components'; +import { fetchApiRef, useApi } from '@backstage/core-plugin-api'; +import useAsync from 'react-use/lib/useAsync'; + +const useStyles = makeStyles({ + avatar: { + height: 32, + width: 32, + borderRadius: '50%', + }, +}); + +type User = { + gender: string; // "male" + name: { + title: string; // "Mr", + first: string; // "Duane", + last: string; // "Reed" + }; + location: object; // {street: {number: 5060, name: "Hickory Creek Dr"}, city: "Albany", state: "New South Wales",…} + email: string; // "duane.reed@example.com" + login: object; // {uuid: "4b785022-9a23-4ab9-8a23-cb3fb43969a9", username: "blackdog796", password: "patch",…} + dob: object; // {date: "1983-06-22T12:30:23.016Z", age: 37} + registered: object; // {date: "2006-06-13T18:48:28.037Z", age: 14} + phone: string; // "07-2154-5651" + cell: string; // "0405-592-879" + id: { + name: string; // "TFN", + value: string; // "796260432" + }; + picture: { medium: string }; // {medium: "https://randomuser.me/api/portraits/men/95.jpg",…} + nat: string; // "AU" +}; + +type DenseTableProps = { + users: User[]; +}; + +export const DenseTable = ({ users }: DenseTableProps) => { + const classes = useStyles(); + + const columns: TableColumn[] = [ + { title: 'Avatar', field: 'avatar' }, + { title: 'Name', field: 'name' }, + { title: 'Email', field: 'email' }, + { title: 'Nationality', field: 'nationality' }, + ]; + + const data = users.map(user => { + return { + avatar: ( + {user.name.first} + ), + name: `${user.name.first} ${user.name.last}`, + email: user.email, + nationality: user.nat, + }; + }); + + return ( + + ); +}; + +export const ExampleFetchComponent = () => { + const { fetch } = useApi(fetchApiRef); + const { value, loading, error } = useAsync(async (): Promise => { + const response = await fetch('https://randomuser.me/api/?results=20'); + const data = await response.json(); + return data.results; + }, []); + + if (loading) { + return ; + } else if (error) { + return ; + } + + return ; +}; diff --git a/plugins/nomad/src/components/ExampleFetchComponent/index.ts b/plugins/nomad/src/components/ExampleFetchComponent/index.ts new file mode 100644 index 0000000000..9644c904ea --- /dev/null +++ b/plugins/nomad/src/components/ExampleFetchComponent/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 { ExampleFetchComponent } from './ExampleFetchComponent'; diff --git a/plugins/nomad/src/index.ts b/plugins/nomad/src/index.ts new file mode 100644 index 0000000000..662b52cd97 --- /dev/null +++ b/plugins/nomad/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 { nomadPlugin, NomadPage } from './plugin'; diff --git a/plugins/nomad/src/plugin.test.ts b/plugins/nomad/src/plugin.test.ts new file mode 100644 index 0000000000..0de9050c70 --- /dev/null +++ b/plugins/nomad/src/plugin.test.ts @@ -0,0 +1,22 @@ +/* + * 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 } from './plugin'; + +describe('nomad', () => { + it('should export plugin', () => { + expect(nomadPlugin).toBeDefined(); + }); +}); diff --git a/plugins/nomad/src/plugin.ts b/plugins/nomad/src/plugin.ts new file mode 100644 index 0000000000..e200d2c5ac --- /dev/null +++ b/plugins/nomad/src/plugin.ts @@ -0,0 +1,37 @@ +/* + * 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 { + createPlugin, + createRoutableExtension, +} from '@backstage/core-plugin-api'; + +import { rootRouteRef } from './routes'; + +export const nomadPlugin = createPlugin({ + id: 'nomad', + routes: { + root: rootRouteRef, + }, +}); + +export const NomadPage = nomadPlugin.provide( + createRoutableExtension({ + name: 'NomadPage', + component: () => + import('./components/ExampleComponent').then(m => m.ExampleComponent), + mountPoint: rootRouteRef, + }), +); 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 bb25e3b9ed..29957c4f56 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7926,6 +7926,32 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-nomad@^0.0.0, @backstage/plugin-nomad@workspace:plugins/nomad": + version: 0.0.0-use.local + resolution: "@backstage/plugin-nomad@workspace:plugins/nomad" + dependencies: + "@backstage/cli": "workspace:^" + "@backstage/core-app-api": "workspace:^" + "@backstage/core-components": "workspace:^" + "@backstage/core-plugin-api": "workspace:^" + "@backstage/dev-utils": "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 + msw: ^1.0.0 + react-use: ^17.2.4 + peerDependencies: + react: ^16.13.1 || ^17.0.0 + 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" @@ -24521,6 +24547,7 @@ __metadata: "@backstage/plugin-microsoft-calendar": "workspace:^" "@backstage/plugin-newrelic": "workspace:^" "@backstage/plugin-newrelic-dashboard": "workspace:^" + "@backstage/plugin-nomad": ^0.0.0 "@backstage/plugin-octopus-deploy": "workspace:^" "@backstage/plugin-org": "workspace:^" "@backstage/plugin-pagerduty": "workspace:^" From 77ac78c1da28e1d14fac6131012fab5a1f26005e Mon Sep 17 00:00:00 2001 From: josh Date: Sun, 21 May 2023 20:34:28 -0400 Subject: [PATCH 02/34] Rename examples Signed-off-by: josh --- plugins/nomad/package.json | 1 + .../NomadComponent.test.tsx} | 6 ++-- .../NomadComponent.tsx} | 6 ++-- .../index.ts | 2 +- .../NomadFetchComponent.test.tsx} | 6 ++-- .../NomadFetchComponent.tsx} | 2 +- .../index.ts | 2 +- plugins/nomad/src/components/Router.tsx | 32 +++++++++++++++++++ plugins/nomad/src/plugin.ts | 2 +- yarn.lock | 1 + 10 files changed, 47 insertions(+), 13 deletions(-) rename plugins/nomad/src/components/{ExampleComponent/ExampleComponent.test.tsx => NomadComponent/NomadComponent.test.tsx} (89%) rename plugins/nomad/src/components/{ExampleComponent/ExampleComponent.tsx => NomadComponent/NomadComponent.tsx} (91%) rename plugins/nomad/src/components/{ExampleComponent => NomadComponent}/index.ts (91%) rename plugins/nomad/src/components/{ExampleFetchComponent/ExampleFetchComponent.test.tsx => NomadFetchComponent/NomadFetchComponent.test.tsx} (89%) rename plugins/nomad/src/components/{ExampleFetchComponent/ExampleFetchComponent.tsx => NomadFetchComponent/NomadFetchComponent.tsx} (98%) rename plugins/nomad/src/components/{ExampleFetchComponent => NomadFetchComponent}/index.ts (90%) create mode 100644 plugins/nomad/src/components/Router.tsx diff --git a/plugins/nomad/package.json b/plugins/nomad/package.json index 17231e7b24..e195a2fbe0 100644 --- a/plugins/nomad/package.json +++ b/plugins/nomad/package.json @@ -22,6 +22,7 @@ "postpack": "backstage-cli package postpack" }, "dependencies": { + "@backstage/catalog-model": "workspace:^", "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", "@backstage/theme": "workspace:^", diff --git a/plugins/nomad/src/components/ExampleComponent/ExampleComponent.test.tsx b/plugins/nomad/src/components/NomadComponent/NomadComponent.test.tsx similarity index 89% rename from plugins/nomad/src/components/ExampleComponent/ExampleComponent.test.tsx rename to plugins/nomad/src/components/NomadComponent/NomadComponent.test.tsx index b0fb163116..31b7319fd6 100644 --- a/plugins/nomad/src/components/ExampleComponent/ExampleComponent.test.tsx +++ b/plugins/nomad/src/components/NomadComponent/NomadComponent.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ import React from 'react'; -import { ExampleComponent } from './ExampleComponent'; +import { NomadComponent } from './NomadComponent'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { screen } from '@testing-library/react'; @@ -23,7 +23,7 @@ import { renderInTestApp, } from '@backstage/test-utils'; -describe('ExampleComponent', () => { +describe('NomadComponent', () => { const server = setupServer(); // Enable sane handlers for network requests setupRequestMockHandlers(server); @@ -36,7 +36,7 @@ describe('ExampleComponent', () => { }); it('should render', async () => { - await renderInTestApp(); + await renderInTestApp(); expect(screen.getByText('Welcome to nomad!')).toBeInTheDocument(); }); }); diff --git a/plugins/nomad/src/components/ExampleComponent/ExampleComponent.tsx b/plugins/nomad/src/components/NomadComponent/NomadComponent.tsx similarity index 91% rename from plugins/nomad/src/components/ExampleComponent/ExampleComponent.tsx rename to plugins/nomad/src/components/NomadComponent/NomadComponent.tsx index 0a0d8809fc..cd7334d7eb 100644 --- a/plugins/nomad/src/components/ExampleComponent/ExampleComponent.tsx +++ b/plugins/nomad/src/components/NomadComponent/NomadComponent.tsx @@ -24,9 +24,9 @@ import { HeaderLabel, SupportButton, } from '@backstage/core-components'; -import { ExampleFetchComponent } from '../ExampleFetchComponent'; +import { NomadFetchComponent } from '../NomadFetchComponent'; -export const ExampleComponent = () => ( +export const NomadComponent = () => (
@@ -45,7 +45,7 @@ export const ExampleComponent = () => ( - + diff --git a/plugins/nomad/src/components/ExampleComponent/index.ts b/plugins/nomad/src/components/NomadComponent/index.ts similarity index 91% rename from plugins/nomad/src/components/ExampleComponent/index.ts rename to plugins/nomad/src/components/NomadComponent/index.ts index 7e520a7ab8..aeecd0d9e6 100644 --- a/plugins/nomad/src/components/ExampleComponent/index.ts +++ b/plugins/nomad/src/components/NomadComponent/index.ts @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { ExampleComponent } from './ExampleComponent'; +export { NomadComponent } from './NomadComponent'; diff --git a/plugins/nomad/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx b/plugins/nomad/src/components/NomadFetchComponent/NomadFetchComponent.test.tsx similarity index 89% rename from plugins/nomad/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx rename to plugins/nomad/src/components/NomadFetchComponent/NomadFetchComponent.test.tsx index a714f640da..48c27dce05 100644 --- a/plugins/nomad/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx +++ b/plugins/nomad/src/components/NomadFetchComponent/NomadFetchComponent.test.tsx @@ -15,12 +15,12 @@ */ import React from 'react'; import { render, screen } from '@testing-library/react'; -import { ExampleFetchComponent } from './ExampleFetchComponent'; +import { NomadFetchComponent } from './NomadFetchComponent'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { setupRequestMockHandlers } from '@backstage/test-utils'; -describe('ExampleFetchComponent', () => { +describe('NomadFetchComponent', () => { const server = setupServer(); // Enable sane handlers for network requests setupRequestMockHandlers(server); @@ -34,7 +34,7 @@ describe('ExampleFetchComponent', () => { ); }); it('should render', async () => { - await render(); + await render(); expect(await screen.findByTestId('progress')).toBeInTheDocument(); }); }); diff --git a/plugins/nomad/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx b/plugins/nomad/src/components/NomadFetchComponent/NomadFetchComponent.tsx similarity index 98% rename from plugins/nomad/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx rename to plugins/nomad/src/components/NomadFetchComponent/NomadFetchComponent.tsx index c3403e1923..0cd3061a3e 100644 --- a/plugins/nomad/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx +++ b/plugins/nomad/src/components/NomadFetchComponent/NomadFetchComponent.tsx @@ -93,7 +93,7 @@ export const DenseTable = ({ users }: DenseTableProps) => { ); }; -export const ExampleFetchComponent = () => { +export const NomadFetchComponent = () => { const { fetch } = useApi(fetchApiRef); const { value, loading, error } = useAsync(async (): Promise => { const response = await fetch('https://randomuser.me/api/?results=20'); diff --git a/plugins/nomad/src/components/ExampleFetchComponent/index.ts b/plugins/nomad/src/components/NomadFetchComponent/index.ts similarity index 90% rename from plugins/nomad/src/components/ExampleFetchComponent/index.ts rename to plugins/nomad/src/components/NomadFetchComponent/index.ts index 9644c904ea..8e30a21fd1 100644 --- a/plugins/nomad/src/components/ExampleFetchComponent/index.ts +++ b/plugins/nomad/src/components/NomadFetchComponent/index.ts @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { ExampleFetchComponent } from './ExampleFetchComponent'; +export { NomadFetchComponent } from './NomadFetchComponent'; diff --git a/plugins/nomad/src/components/Router.tsx b/plugins/nomad/src/components/Router.tsx new file mode 100644 index 0000000000..c6b4ac47e8 --- /dev/null +++ b/plugins/nomad/src/components/Router.tsx @@ -0,0 +1,32 @@ +/* + * 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 { Entity } from '@backstage/catalog-model'; + +/** @public */ +export const NOMAD_JOB_ANNOTATION = 'nomad.io/job'; + +/** @public */ +export const NOMAD_GROUP_ANNOTATION = 'nomad.io/group'; + +/** @public */ +export const NOMAD_TASK_ANNOTATION = 'nomad.io/task'; + +/** @public */ +export const isNomadAvailable = (entity: Entity) => + Boolean(entity.metadata.annotations?.[NOMAD_JOB_ANNOTATION]) || + Boolean(entity.metadata.annotations?.[NOMAD_GROUP_ANNOTATION]) || + Boolean(entity.metadata.annotations?.[NOMAD_TASK_ANNOTATION]); diff --git a/plugins/nomad/src/plugin.ts b/plugins/nomad/src/plugin.ts index e200d2c5ac..c5c8f81aad 100644 --- a/plugins/nomad/src/plugin.ts +++ b/plugins/nomad/src/plugin.ts @@ -31,7 +31,7 @@ export const NomadPage = nomadPlugin.provide( createRoutableExtension({ name: 'NomadPage', component: () => - import('./components/ExampleComponent').then(m => m.ExampleComponent), + import('./components/NomadComponent').then(m => m.NomadComponent), mountPoint: rootRouteRef, }), ); diff --git a/yarn.lock b/yarn.lock index 29957c4f56..9417d52474 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7930,6 +7930,7 @@ __metadata: version: 0.0.0-use.local resolution: "@backstage/plugin-nomad@workspace:plugins/nomad" dependencies: + "@backstage/catalog-model": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/core-app-api": "workspace:^" "@backstage/core-components": "workspace:^" From 59102979a54f3dc036c47886457e5b9d8df0bc9d Mon Sep 17 00:00:00 2001 From: josh Date: Sun, 21 May 2023 22:40:42 -0400 Subject: [PATCH 03/34] Start adding api Signed-off-by: josh --- plugins/nomad/package.json | 5 +- plugins/nomad/src/{components => }/Router.tsx | 24 ++++- plugins/nomad/src/api.ts | 91 +++++++++++++++++++ .../GroupList/GroupListForEntity.tsx | 31 +++++++ plugins/nomad/src/hooks/useGroupForEntity.tsx | 26 ++++++ plugins/nomad/src/plugin.test.ts | 14 ++- plugins/nomad/src/plugin.ts | 26 ++++++ yarn.lock | 3 + 8 files changed, 214 insertions(+), 6 deletions(-) rename plugins/nomad/src/{components => }/Router.tsx (63%) create mode 100644 plugins/nomad/src/api.ts create mode 100644 plugins/nomad/src/components/GroupList/GroupListForEntity.tsx create mode 100644 plugins/nomad/src/hooks/useGroupForEntity.tsx diff --git a/plugins/nomad/package.json b/plugins/nomad/package.json index e195a2fbe0..79a0fb0607 100644 --- a/plugins/nomad/package.json +++ b/plugins/nomad/package.json @@ -23,8 +23,10 @@ }, "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", @@ -32,7 +34,8 @@ "react-use": "^17.2.4" }, "peerDependencies": { - "react": "^16.13.1 || ^17.0.0" + "react": "^16.13.1 || ^17.0.0", + "react-router-dom": "*" }, "devDependencies": { "@backstage/cli": "workspace:^", diff --git a/plugins/nomad/src/components/Router.tsx b/plugins/nomad/src/Router.tsx similarity index 63% rename from plugins/nomad/src/components/Router.tsx rename to plugins/nomad/src/Router.tsx index c6b4ac47e8..d65fda777a 100644 --- a/plugins/nomad/src/components/Router.tsx +++ b/plugins/nomad/src/Router.tsx @@ -14,10 +14,12 @@ * limitations under the License. */ +import React from 'react'; import { Entity } from '@backstage/catalog-model'; - -/** @public */ -export const NOMAD_JOB_ANNOTATION = 'nomad.io/job'; +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'; /** @public */ export const NOMAD_GROUP_ANNOTATION = 'nomad.io/group'; @@ -27,6 +29,20 @@ export const NOMAD_TASK_ANNOTATION = 'nomad.io/task'; /** @public */ export const isNomadAvailable = (entity: Entity) => - Boolean(entity.metadata.annotations?.[NOMAD_JOB_ANNOTATION]) || Boolean(entity.metadata.annotations?.[NOMAD_GROUP_ANNOTATION]) || Boolean(entity.metadata.annotations?.[NOMAD_TASK_ANNOTATION]); + +/** @public */ +export const EmbeddedRouter = () => { + const { entity } = useEntity(); + + if (!isNomadAvailable(entity)) { + return ; + } + + return ( + + } /> + + ); +}; diff --git a/plugins/nomad/src/api.ts b/plugins/nomad/src/api.ts new file mode 100644 index 0000000000..ca5113524b --- /dev/null +++ b/plugins/nomad/src/api.ts @@ -0,0 +1,91 @@ +/* + * 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 { Config } from '@backstage/config'; +import { 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 'prefix'. + * + * See: https://developer.hashicorp.com/nomad/api-docs/allocations#list-allocations + */ + listAllocations: ( + options: ListAllocationsRequest, + ) => Promise; +}; + +/** @public */ +export interface ListAllocationsRequest { + namespace?: string; + filter?: string; +} + +/** @public */ +export interface ListAllocationsResponse { + allocations: Allocation[]; +} + +/** @public */ +export interface Allocation { + id: string; + clientStatus: string; + createTime: number; + namespace: string; + name: string; + modifyTime: number; + nodeID: string; + taskGroup: string; +} + +/** @public */ +export interface DeploymentStatus { + healthy: boolean; + timestamp: string; +} + +/** @public */ +export class NomadHttpApi implements NomadApi { + static fromConfig(config: Config) { + return new NomadHttpApi( + config.getOptionalString('nomad.nomadAddr'), + config.getOptionalString('nomad.nomadRegion'), + config.getOptionalString('nomad.nomadNamespace'), + config.getOptionalString('nomad.nomadToken'), + ); + } + + constructor( + private nomadAddr?: string, + private nomadRegion?: string, + private nomadNamespace?: string, + private nomadToken?: string, + ) {} + + async listAllocations( + options: ListAllocationsRequest, + ): Promise { + return Promise.resolve({ + allocations: [], + }); + } +} diff --git a/plugins/nomad/src/components/GroupList/GroupListForEntity.tsx b/plugins/nomad/src/components/GroupList/GroupListForEntity.tsx new file mode 100644 index 0000000000..101d894a81 --- /dev/null +++ b/plugins/nomad/src/components/GroupList/GroupListForEntity.tsx @@ -0,0 +1,31 @@ +/* + * 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 { Table } from '@backstage/core-components'; +import React from 'react'; + +export const GroupListForEntity = () => { + return ( +
+ ); +}; diff --git a/plugins/nomad/src/hooks/useGroupForEntity.tsx b/plugins/nomad/src/hooks/useGroupForEntity.tsx new file mode 100644 index 0000000000..22a853cbb1 --- /dev/null +++ b/plugins/nomad/src/hooks/useGroupForEntity.tsx @@ -0,0 +1,26 @@ +/* + * 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 { useEntity } from '@backstage/plugin-catalog-react'; +import { NOMAD_GROUP_ANNOTATION } from '../Router'; + +/** + * Get the entity's group and query it from the Nomad API. + */ +export const useGroupForEntity = () => { + const { entity } = useEntity(); + const group = entity.metadata.annotations?.[NOMAD_GROUP_ANNOTATION] ?? ''; +}; diff --git a/plugins/nomad/src/plugin.test.ts b/plugins/nomad/src/plugin.test.ts index 0de9050c70..def0f8ab89 100644 --- a/plugins/nomad/src/plugin.test.ts +++ b/plugins/nomad/src/plugin.test.ts @@ -13,10 +13,22 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { nomadPlugin } from './plugin'; +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 index c5c8f81aad..535b898723 100644 --- a/plugins/nomad/src/plugin.ts +++ b/plugins/nomad/src/plugin.ts @@ -14,19 +14,36 @@ * limitations under the License. */ import { + configApiRef, + createApiFactory, createPlugin, createRoutableExtension, + createRouteRef, } from '@backstage/core-plugin-api'; import { rootRouteRef } from './routes'; +import { NomadHttpApi, nomadApiRef } from './api'; + +export const entityContentRouteRef = createRouteRef({ + id: 'nomad:entity-content', +}); export const nomadPlugin = createPlugin({ id: 'nomad', + apis: [ + createApiFactory({ + api: nomadApiRef, + deps: { configApi: configApiRef }, + factory: ({ configApi }) => NomadHttpApi.fromConfig(configApi), + }), + ], routes: { root: rootRouteRef, + entityContent: entityContentRouteRef, }, }); +/** @public */ export const NomadPage = nomadPlugin.provide( createRoutableExtension({ name: 'NomadPage', @@ -35,3 +52,12 @@ export const NomadPage = nomadPlugin.provide( mountPoint: rootRouteRef, }), ); + +/** @public */ +export const EntityNomadContent = nomadPlugin.provide( + createRoutableExtension({ + name: 'EntityNomadContent', + component: () => import('./Router').then(m => m.EmbeddedRouter), + mountPoint: entityContentRouteRef, + }), +); diff --git a/yarn.lock b/yarn.lock index 9417d52474..919ab12667 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7932,10 +7932,12 @@ __metadata: 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 @@ -7950,6 +7952,7 @@ __metadata: react-use: ^17.2.4 peerDependencies: react: ^16.13.1 || ^17.0.0 + react-router-dom: "*" languageName: unknown linkType: soft From 86497612b6b6285d48ff937a80839ca4d0cb1af4 Mon Sep 17 00:00:00 2001 From: josh Date: Mon, 29 May 2023 19:11:52 -0400 Subject: [PATCH 04/34] Add table component Signed-off-by: josh --- .../app/src/components/catalog/EntityPage.tsx | 5 ++ plugins/nomad/src/Router.tsx | 9 ++- plugins/nomad/src/api.ts | 70 +++++++++++++------ .../GroupList/GroupListForEntity.tsx | 24 ++++--- .../components/GroupList/GroupListTable.tsx | 47 +++++++++++++ plugins/nomad/src/hooks/useGroupForEntity.tsx | 25 ++++++- plugins/nomad/src/index.ts | 3 +- 7 files changed, 143 insertions(+), 40 deletions(-) create mode 100644 plugins/nomad/src/components/GroupList/GroupListTable.tsx diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index ca6d09e318..c4450c8261 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -112,6 +112,7 @@ import { EntityOwnershipCard, EntityUserProfileCard, } from '@backstage/plugin-org'; +import { EntityNomadContent, isNomadAvailable } from '@backstage/plugin-nomad'; import { EntityPagerDutyCard, isPagerDutyAvailable, @@ -501,6 +502,10 @@ const serviceEntityPage = ( + + + + {pullRequestsContent} diff --git a/plugins/nomad/src/Router.tsx b/plugins/nomad/src/Router.tsx index d65fda777a..7615d6ad99 100644 --- a/plugins/nomad/src/Router.tsx +++ b/plugins/nomad/src/Router.tsx @@ -21,16 +21,15 @@ import { MissingAnnotationEmptyState } from '@backstage/core-components'; import { Route, Routes } from 'react-router-dom'; import { GroupListForEntity } from './components/GroupList/GroupListForEntity'; +/** @public */ +export const NOMAD_NAMESPACE_ANNOTATION = 'nomad.io/namespace'; + /** @public */ export const NOMAD_GROUP_ANNOTATION = 'nomad.io/group'; -/** @public */ -export const NOMAD_TASK_ANNOTATION = 'nomad.io/task'; - /** @public */ export const isNomadAvailable = (entity: Entity) => - Boolean(entity.metadata.annotations?.[NOMAD_GROUP_ANNOTATION]) || - Boolean(entity.metadata.annotations?.[NOMAD_TASK_ANNOTATION]); + Boolean(entity.metadata.annotations?.[NOMAD_GROUP_ANNOTATION]); /** @public */ export const EmbeddedRouter = () => { diff --git a/plugins/nomad/src/api.ts b/plugins/nomad/src/api.ts index ca5113524b..e9a967e2ae 100644 --- a/plugins/nomad/src/api.ts +++ b/plugins/nomad/src/api.ts @@ -25,7 +25,7 @@ export const nomadApiRef = createApiRef({ /** @public */ export type NomadApi = { /** - * listAllocations is for listing all allocations matching some part of 'prefix'. + * listAllocations is for listing all allocations matching some part of 'filter'. * * See: https://developer.hashicorp.com/nomad/api-docs/allocations#list-allocations */ @@ -36,8 +36,8 @@ export type NomadApi = { /** @public */ export interface ListAllocationsRequest { - namespace?: string; - filter?: string; + namespace: string; + filter: string; } /** @public */ @@ -47,45 +47,71 @@ export interface ListAllocationsResponse { /** @public */ export interface Allocation { - id: string; - clientStatus: string; - createTime: number; - namespace: string; - name: string; - modifyTime: number; - nodeID: string; - taskGroup: string; + ClientStatus: string; + CreateTime: number; + DeploymentStatus: DeploymentStatus; + ID: string; + ModifyTime: number; + Name: string; + Namespace: string; + NodeID: string; + TaskGroup: string; } /** @public */ export interface DeploymentStatus { - healthy: boolean; - timestamp: string; + Healthy: boolean; + Timestamp: string; +} + +/** @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 fromConfig(config: Config) { return new NomadHttpApi( - config.getOptionalString('nomad.nomadAddr'), - config.getOptionalString('nomad.nomadRegion'), - config.getOptionalString('nomad.nomadNamespace'), - config.getOptionalString('nomad.nomadToken'), + config.getOptionalString('nomad.addr'), + config.getOptionalString('nomad.token'), ); } constructor( - private nomadAddr?: string, - private nomadRegion?: string, - private nomadNamespace?: string, - private nomadToken?: string, + private addr: string = 'http://127.0.0.1:4646', + private token?: string, ) {} + // TODO: pagination async listAllocations( options: ListAllocationsRequest, ): Promise { + const resp = await fetch( + `${this.addr}/v1/allocations?namespace=${encodeURIComponent( + options.namespace, + )}&filter=${encodeURIComponent(options.filter)}`, + { + method: 'GET', + headers: { + 'X-Nomad-Token': this.token || '', + }, + }, + ); + if (!resp.ok) throw await FetchError.forResponse(resp); + return Promise.resolve({ - allocations: [], + allocations: await resp.json(), }); } } diff --git a/plugins/nomad/src/components/GroupList/GroupListForEntity.tsx b/plugins/nomad/src/components/GroupList/GroupListForEntity.tsx index 101d894a81..b6323dcf87 100644 --- a/plugins/nomad/src/components/GroupList/GroupListForEntity.tsx +++ b/plugins/nomad/src/components/GroupList/GroupListForEntity.tsx @@ -14,18 +14,20 @@ * limitations under the License. */ -import { Table } from '@backstage/core-components'; +import { Progress } from '@backstage/core-components'; import React from 'react'; +import { useGroupForEntity } from '../../hooks/useGroupForEntity'; +import { GroupListTable } from './GroupListTable'; export const GroupListForEntity = () => { - return ( -
- ); + const { value, loading, error } = useGroupForEntity(); + + if (loading) { + return ; + } + if (error || !value) { + return null; + } + + return ; }; diff --git a/plugins/nomad/src/components/GroupList/GroupListTable.tsx b/plugins/nomad/src/components/GroupList/GroupListTable.tsx new file mode 100644 index 0000000000..b427946805 --- /dev/null +++ b/plugins/nomad/src/components/GroupList/GroupListTable.tsx @@ -0,0 +1,47 @@ +/* + * 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 { Table, TableColumn } from '@backstage/core-components'; +import React from 'react'; +import { Allocation } from '../../api'; + +const columns: TableColumn[] = [ + { + title: 'Allocation ID', + field: 'ID', + }, + { + title: 'Name', + field: 'Name', + }, +]; + +export const GroupListTable = ({ + allocations, +}: { + allocations: Allocation[]; +}) => { + return ( +
+ ); +}; diff --git a/plugins/nomad/src/hooks/useGroupForEntity.tsx b/plugins/nomad/src/hooks/useGroupForEntity.tsx index 22a853cbb1..fac3bff598 100644 --- a/plugins/nomad/src/hooks/useGroupForEntity.tsx +++ b/plugins/nomad/src/hooks/useGroupForEntity.tsx @@ -15,12 +15,35 @@ */ import { useEntity } from '@backstage/plugin-catalog-react'; -import { NOMAD_GROUP_ANNOTATION } from '../Router'; +import { NOMAD_GROUP_ANNOTATION, NOMAD_NAMESPACE_ANNOTATION } from '../Router'; +import { errorApiRef, useApi } from '@backstage/core-plugin-api'; +import { nomadApiRef } from '../api'; +import useAsync from 'react-use/lib/useAsync'; /** * Get the entity's group and query it from the Nomad API. */ export const useGroupForEntity = () => { const { entity } = useEntity(); + + const namespace = + entity.metadata.annotations?.[NOMAD_NAMESPACE_ANNOTATION] ?? 'default'; const group = entity.metadata.annotations?.[NOMAD_GROUP_ANNOTATION] ?? ''; + + const nomadApi = useApi(nomadApiRef); + const errorApi = useApi(errorApiRef); + + const response = useAsync( + () => + nomadApi.listAllocations({ + namespace, + filter: `TaskGroup == "${group}"`, + }), + [group], + ); + + if (response.error) { + errorApi.post(response.error); + } + return response; }; diff --git a/plugins/nomad/src/index.ts b/plugins/nomad/src/index.ts index 662b52cd97..59ae5efaee 100644 --- a/plugins/nomad/src/index.ts +++ b/plugins/nomad/src/index.ts @@ -13,4 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { nomadPlugin, NomadPage } from './plugin'; +export { nomadPlugin, NomadPage, EntityNomadContent } from './plugin'; +export { isNomadAvailable, EmbeddedRouter } from './Router'; From 90913537ffcab9dccf6d196aea39e16dba920b71 Mon Sep 17 00:00:00 2001 From: josh Date: Mon, 29 May 2023 21:17:12 -0400 Subject: [PATCH 05/34] Get rid of hook Signed-off-by: josh --- packages/app/package.json | 2 +- .../app/src/components/catalog/EntityPage.tsx | 2 +- plugins/nomad/package.json | 24 ++++++++- .../GroupList/GroupListForEntity.tsx | 45 ++++++++++++++--- plugins/nomad/src/hooks/useGroupForEntity.tsx | 49 ------------------- yarn.lock | 4 +- 6 files changed, 66 insertions(+), 60 deletions(-) delete mode 100644 plugins/nomad/src/hooks/useGroupForEntity.tsx diff --git a/packages/app/package.json b/packages/app/package.json index a41e7f1abf..60f753926b 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -50,7 +50,7 @@ "@backstage/plugin-microsoft-calendar": "workspace:^", "@backstage/plugin-newrelic": "workspace:^", "@backstage/plugin-newrelic-dashboard": "workspace:^", - "@backstage/plugin-nomad": "^0.0.0", + "@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 c4450c8261..f6950f5c62 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -112,7 +112,7 @@ import { EntityOwnershipCard, EntityUserProfileCard, } from '@backstage/plugin-org'; -import { EntityNomadContent, isNomadAvailable } from '@backstage/plugin-nomad'; +import { EntityNomadContent } from '@backstage/plugin-nomad'; import { EntityPagerDutyCard, isPagerDutyAvailable, diff --git a/plugins/nomad/package.json b/plugins/nomad/package.json index 79a0fb0607..18a343ebd2 100644 --- a/plugins/nomad/package.json +++ b/plugins/nomad/package.json @@ -51,5 +51,27 @@ }, "files": [ "dist" - ] + ], + "configSchema": { + "$schema": "https://backstage.io/schema/config-v1", + "title": "@backstage/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" + } + } + } + } + } } diff --git a/plugins/nomad/src/components/GroupList/GroupListForEntity.tsx b/plugins/nomad/src/components/GroupList/GroupListForEntity.tsx index b6323dcf87..d8af936569 100644 --- a/plugins/nomad/src/components/GroupList/GroupListForEntity.tsx +++ b/plugins/nomad/src/components/GroupList/GroupListForEntity.tsx @@ -15,19 +15,52 @@ */ import { Progress } from '@backstage/core-components'; -import React from 'react'; -import { useGroupForEntity } from '../../hooks/useGroupForEntity'; +import React, { useState } from 'react'; import { GroupListTable } from './GroupListTable'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { Allocation, nomadApiRef } from '../../api'; +import { ErrorApiError, errorApiRef, useApi } from '@backstage/core-plugin-api'; +import useAsync from 'react-use/lib/useAsync'; +import { + NOMAD_GROUP_ANNOTATION, + NOMAD_NAMESPACE_ANNOTATION, +} from '../../Router'; export const GroupListForEntity = () => { - const { value, loading, error } = useGroupForEntity(); + const { entity } = useEntity(); - if (loading) { + const namespace = + entity.metadata.annotations?.[NOMAD_NAMESPACE_ANNOTATION] ?? 'default'; + const group = entity.metadata.annotations?.[NOMAD_GROUP_ANNOTATION] ?? ''; + + const nomadApi = useApi(nomadApiRef); + const errorApi = useApi(errorApiRef); + + // Retrieve allocations for the group + const [allocations, setAllocations] = useState([]); + const response = useAsync(async () => { + // Wait until entity is loaded + if (!entity) { + return; + } + + try { + const resp = await nomadApi.listAllocations({ + namespace, + filter: `TaskGroup == "${group}"`, + }); + setAllocations(resp.allocations); + } catch (e) { + errorApi.post(e as ErrorApiError); + } + }, [group]); + + if (response.loading) { return ; } - if (error || !value) { + if (response.error || !response.value) { return null; } - return ; + return ; }; diff --git a/plugins/nomad/src/hooks/useGroupForEntity.tsx b/plugins/nomad/src/hooks/useGroupForEntity.tsx deleted file mode 100644 index fac3bff598..0000000000 --- a/plugins/nomad/src/hooks/useGroupForEntity.tsx +++ /dev/null @@ -1,49 +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 { useEntity } from '@backstage/plugin-catalog-react'; -import { NOMAD_GROUP_ANNOTATION, NOMAD_NAMESPACE_ANNOTATION } from '../Router'; -import { errorApiRef, useApi } from '@backstage/core-plugin-api'; -import { nomadApiRef } from '../api'; -import useAsync from 'react-use/lib/useAsync'; - -/** - * Get the entity's group and query it from the Nomad API. - */ -export const useGroupForEntity = () => { - const { entity } = useEntity(); - - const namespace = - entity.metadata.annotations?.[NOMAD_NAMESPACE_ANNOTATION] ?? 'default'; - const group = entity.metadata.annotations?.[NOMAD_GROUP_ANNOTATION] ?? ''; - - const nomadApi = useApi(nomadApiRef); - const errorApi = useApi(errorApiRef); - - const response = useAsync( - () => - nomadApi.listAllocations({ - namespace, - filter: `TaskGroup == "${group}"`, - }), - [group], - ); - - if (response.error) { - errorApi.post(response.error); - } - return response; -}; diff --git a/yarn.lock b/yarn.lock index 919ab12667..34eba288b3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7926,7 +7926,7 @@ __metadata: languageName: unknown linkType: soft -"@backstage/plugin-nomad@^0.0.0, @backstage/plugin-nomad@workspace:plugins/nomad": +"@backstage/plugin-nomad@workspace:^, @backstage/plugin-nomad@workspace:plugins/nomad": version: 0.0.0-use.local resolution: "@backstage/plugin-nomad@workspace:plugins/nomad" dependencies: @@ -24551,7 +24551,7 @@ __metadata: "@backstage/plugin-microsoft-calendar": "workspace:^" "@backstage/plugin-newrelic": "workspace:^" "@backstage/plugin-newrelic-dashboard": "workspace:^" - "@backstage/plugin-nomad": ^0.0.0 + "@backstage/plugin-nomad": "workspace:^" "@backstage/plugin-octopus-deploy": "workspace:^" "@backstage/plugin-org": "workspace:^" "@backstage/plugin-pagerduty": "workspace:^" From b373ade3445dc32fc846391cb671f7714890bb05 Mon Sep 17 00:00:00 2001 From: josh Date: Sun, 4 Jun 2023 15:33:41 -0400 Subject: [PATCH 06/34] Add Nomad Backend plugin Signed-off-by: josh --- .github/CODEOWNERS | 1 + packages/backend/package.json | 1 + packages/backend/src/index.ts | 3 + packages/backend/src/plugins/nomad.ts | 32 ++++++++ plugins/nomad-backend/.eslintrc.js | 1 + plugins/nomad-backend/README.md | 14 ++++ plugins/nomad-backend/package.json | 69 ++++++++++++++++ plugins/nomad-backend/src/index.ts | 16 ++++ plugins/nomad-backend/src/run.ts | 32 ++++++++ .../nomad-backend/src/service/router.test.ts | 44 ++++++++++ plugins/nomad-backend/src/service/router.ts | 80 +++++++++++++++++++ .../src/service/standaloneServer.ts | 52 ++++++++++++ plugins/nomad-backend/src/setupTests.ts | 16 ++++ plugins/nomad/package.json | 24 +----- plugins/nomad/src/api.ts | 31 +++---- .../GroupList/GroupListForEntity.tsx | 3 +- plugins/nomad/src/plugin.ts | 8 +- yarn.lock | 21 +++++ 18 files changed, 402 insertions(+), 46 deletions(-) create mode 100644 packages/backend/src/plugins/nomad.ts create mode 100644 plugins/nomad-backend/.eslintrc.js create mode 100644 plugins/nomad-backend/README.md create mode 100644 plugins/nomad-backend/package.json create mode 100644 plugins/nomad-backend/src/index.ts create mode 100644 plugins/nomad-backend/src/run.ts create mode 100644 plugins/nomad-backend/src/service/router.test.ts create mode 100644 plugins/nomad-backend/src/service/router.ts create mode 100644 plugins/nomad-backend/src/service/standaloneServer.ts create mode 100644 plugins/nomad-backend/src/setupTests.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f7a7f99250..a3756e437c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -54,6 +54,7 @@ /plugins/gcalendar @backstage/maintainers @szubster @ptychu @kielosz @alexrybch /plugins/git-release-manager @backstage/maintainers @erikengervall /plugins/home @backstage/discoverability-maintainers +/plugins/home-* @backstage/discoverability-maintainers /plugins/ilert @backstage/maintainers @yacut /plugins/jenkins @backstage/maintainers @timja /plugins/jenkins-backend @backstage/maintainers @timja diff --git a/packages/backend/package.json b/packages/backend/package.json index 88937a9e6c..375f86ce00 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..68e4344f98 --- /dev/null +++ b/packages/backend/src/plugins/nomad.ts @@ -0,0 +1,32 @@ +/* + * 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( + env: PluginEnvironment, +): Promise { + // Here is where you will add all of the required initialization code that + // your backend plugin needs to be able to start! + + // The env contains a lot of goodies, but our router currently only + // needs a logger + return await createRouter({ + logger: env.logger, + config: env.config, + }); +} 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..1a670bd9a8 --- /dev/null +++ b/plugins/nomad-backend/README.md @@ -0,0 +1,14 @@ +# nomad + +Welcome to the nomad backend plugin! + +_This plugin was created through the Backstage CLI_ + +## Getting started + +Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn +start` in the root directory, and then navigating to [/nomad-backend](http://localhost:3000/nomad-backend). + +You can also serve the plugin in isolation by running `yarn start` in the plugin directory. +This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads. +It is only meant for local development, and the setup for it can be found inside the [/dev](/dev) directory. diff --git a/plugins/nomad-backend/package.json b/plugins/nomad-backend/package.json new file mode 100644 index 0000000000..0967c8ab65 --- /dev/null +++ b/plugins/nomad-backend/package.json @@ -0,0 +1,69 @@ +{ + "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" + ], + "configSchema": { + "$schema": "https://backstage.io/schema/config-v1", + "title": "@backstage/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": "backend" + }, + "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-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..d299ed23e9 --- /dev/null +++ b/plugins/nomad-backend/src/run.ts @@ -0,0 +1,32 @@ +/* + * 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'; + +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({ 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..c10dc8ba0e --- /dev/null +++ b/plugins/nomad-backend/src/service/router.test.ts @@ -0,0 +1,44 @@ +/* + * 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'; + +describe('createRouter', () => { + let app: express.Express; + + beforeAll(async () => { + const router = await createRouter({ + 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..332fd004e1 --- /dev/null +++ b/plugins/nomad-backend/src/service/router.ts @@ -0,0 +1,80 @@ +/* + * 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 { 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'; + +export interface RouterOptions { + logger: Logger; + config: Config; +} + +export async function createRouter( + options: RouterOptions, +): Promise { + 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'); + + const namespace = (req.query.namespace as string) ?? ''; + if (!namespace || namespace === '') { + throw new InputError(`Missing "namespace" query parameter`); + } + + 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}`, + ); + const allocationsResp = await fetch( + `${addr}/v1/allocations?namespace=${encodeURIComponent( + namespace, + )}&filter=${encodeURIComponent(filter)}`, + { + method: 'GET', + headers: { + Accept: 'application/json', + 'X-Nomad-Token': token || '', + }, + }, + ); + + const allocationsBody = await allocationsResp.json(); + logger.debug(`/v1/allocations response: ${allocationsBody}`); + + resp.json(allocationsBody); + }); + + 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/package.json b/plugins/nomad/package.json index 18a343ebd2..79a0fb0607 100644 --- a/plugins/nomad/package.json +++ b/plugins/nomad/package.json @@ -51,27 +51,5 @@ }, "files": [ "dist" - ], - "configSchema": { - "$schema": "https://backstage.io/schema/config-v1", - "title": "@backstage/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" - } - } - } - } - } + ] } diff --git a/plugins/nomad/src/api.ts b/plugins/nomad/src/api.ts index e9a967e2ae..6280bcbeda 100644 --- a/plugins/nomad/src/api.ts +++ b/plugins/nomad/src/api.ts @@ -14,8 +14,11 @@ * limitations under the License. */ -import { Config } from '@backstage/config'; -import { createApiRef } from '@backstage/core-plugin-api'; +import { + DiscoveryApi, + FetchApi, + createApiRef, +} from '@backstage/core-plugin-api'; /** @public */ export const nomadApiRef = createApiRef({ @@ -81,32 +84,22 @@ export class FetchError extends Error { /** @public */ export class NomadHttpApi implements NomadApi { - static fromConfig(config: Config) { - return new NomadHttpApi( - config.getOptionalString('nomad.addr'), - config.getOptionalString('nomad.token'), - ); + static new(discoveryApi: DiscoveryApi, fetchApi: FetchApi) { + return new NomadHttpApi(discoveryApi, fetchApi); } - constructor( - private addr: string = 'http://127.0.0.1:4646', - private token?: string, - ) {} + constructor(private discoveryApi: DiscoveryApi, private fetchApi: FetchApi) {} // TODO: pagination async listAllocations( options: ListAllocationsRequest, ): Promise { - const resp = await fetch( - `${this.addr}/v1/allocations?namespace=${encodeURIComponent( + const apiUrl = await this.discoveryApi.getBaseUrl('nomad'); + + const resp = await this.fetchApi.fetch( + `${apiUrl}/v1/allocations?namespace=${encodeURIComponent( options.namespace, )}&filter=${encodeURIComponent(options.filter)}`, - { - method: 'GET', - headers: { - 'X-Nomad-Token': this.token || '', - }, - }, ); if (!resp.ok) throw await FetchError.forResponse(resp); diff --git a/plugins/nomad/src/components/GroupList/GroupListForEntity.tsx b/plugins/nomad/src/components/GroupList/GroupListForEntity.tsx index d8af936569..ac11cb2665 100644 --- a/plugins/nomad/src/components/GroupList/GroupListForEntity.tsx +++ b/plugins/nomad/src/components/GroupList/GroupListForEntity.tsx @@ -44,6 +44,7 @@ export const GroupListForEntity = () => { return; } + // Issue call to nomad-backend try { const resp = await nomadApi.listAllocations({ namespace, @@ -58,7 +59,7 @@ export const GroupListForEntity = () => { if (response.loading) { return ; } - if (response.error || !response.value) { + if (response.error) { return null; } diff --git a/plugins/nomad/src/plugin.ts b/plugins/nomad/src/plugin.ts index 535b898723..1b45e803b4 100644 --- a/plugins/nomad/src/plugin.ts +++ b/plugins/nomad/src/plugin.ts @@ -14,7 +14,8 @@ * limitations under the License. */ import { - configApiRef, + discoveryApiRef, + fetchApiRef, createApiFactory, createPlugin, createRoutableExtension, @@ -33,8 +34,9 @@ export const nomadPlugin = createPlugin({ apis: [ createApiFactory({ api: nomadApiRef, - deps: { configApi: configApiRef }, - factory: ({ configApi }) => NomadHttpApi.fromConfig(configApi), + deps: { discoveryApi: discoveryApiRef, fetchApi: fetchApiRef }, + factory: ({ discoveryApi, fetchApi }) => + NomadHttpApi.new(discoveryApi, fetchApi), }), ], routes: { diff --git a/yarn.lock b/yarn.lock index 34eba288b3..b6183636aa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7926,6 +7926,26 @@ __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" @@ -24670,6 +24690,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:^" From 0215f93a5d1ff0c3d829fe6ae59f398474b47df2 Mon Sep 17 00:00:00 2001 From: josh Date: Thu, 1 Jun 2023 23:28:50 -0400 Subject: [PATCH 07/34] Flesh out allocations table Signed-off-by: josh --- plugins/nomad-backend/package.json | 27 +----- plugins/nomad/package.json | 27 +++++- plugins/nomad/src/api.ts | 2 + .../GroupList/AllocationListTable.tsx | 96 +++++++++++++++++++ .../GroupList/GroupListForEntity.tsx | 32 ++++--- .../components/GroupList/GroupListTable.tsx | 47 --------- 6 files changed, 143 insertions(+), 88 deletions(-) create mode 100644 plugins/nomad/src/components/GroupList/AllocationListTable.tsx delete mode 100644 plugins/nomad/src/components/GroupList/GroupListTable.tsx diff --git a/plugins/nomad-backend/package.json b/plugins/nomad-backend/package.json index 0967c8ab65..344d120001 100644 --- a/plugins/nomad-backend/package.json +++ b/plugins/nomad-backend/package.json @@ -40,30 +40,5 @@ }, "files": [ "dist" - ], - "configSchema": { - "$schema": "https://backstage.io/schema/config-v1", - "title": "@backstage/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": "backend" - }, - "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/package.json b/plugins/nomad/package.json index 79a0fb0607..c4d5ad3d75 100644 --- a/plugins/nomad/package.json +++ b/plugins/nomad/package.json @@ -51,5 +51,30 @@ }, "files": [ "dist" - ] + ], + "configSchema": { + "$schema": "https://backstage.io/schema/config-v1", + "title": "@backstage/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/api.ts b/plugins/nomad/src/api.ts index 6280bcbeda..b3057317fe 100644 --- a/plugins/nomad/src/api.ts +++ b/plugins/nomad/src/api.ts @@ -54,6 +54,8 @@ export interface Allocation { CreateTime: number; DeploymentStatus: DeploymentStatus; ID: string; + JobID: string; + JobVersion: string; ModifyTime: number; Name: string; Namespace: string; diff --git a/plugins/nomad/src/components/GroupList/AllocationListTable.tsx b/plugins/nomad/src/components/GroupList/AllocationListTable.tsx new file mode 100644 index 0000000000..bfabfca3a9 --- /dev/null +++ b/plugins/nomad/src/components/GroupList/AllocationListTable.tsx @@ -0,0 +1,96 @@ +/* + * 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 index ac11cb2665..9586136fec 100644 --- a/plugins/nomad/src/components/GroupList/GroupListForEntity.tsx +++ b/plugins/nomad/src/components/GroupList/GroupListForEntity.tsx @@ -16,15 +16,16 @@ import { Progress } from '@backstage/core-components'; import React, { useState } from 'react'; -import { GroupListTable } from './GroupListTable'; +import { AllocationListTable } from './AllocationListTable'; import { useEntity } from '@backstage/plugin-catalog-react'; import { Allocation, nomadApiRef } from '../../api'; -import { ErrorApiError, errorApiRef, useApi } from '@backstage/core-plugin-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(); @@ -33,8 +34,11 @@ export const GroupListForEntity = () => { entity.metadata.annotations?.[NOMAD_NAMESPACE_ANNOTATION] ?? 'default'; const group = entity.metadata.annotations?.[NOMAD_GROUP_ANNOTATION] ?? ''; + const configApi = useApi(configApiRef); const nomadApi = useApi(nomadApiRef); - const errorApi = useApi(errorApiRef); + + // 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([]); @@ -45,23 +49,23 @@ export const GroupListForEntity = () => { } // Issue call to nomad-backend - try { - const resp = await nomadApi.listAllocations({ - namespace, - filter: `TaskGroup == "${group}"`, - }); - setAllocations(resp.allocations); - } catch (e) { - errorApi.post(e as ErrorApiError); - } + 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 null; + return {response.error.message}; } - return ; + return ( + + ); }; diff --git a/plugins/nomad/src/components/GroupList/GroupListTable.tsx b/plugins/nomad/src/components/GroupList/GroupListTable.tsx deleted file mode 100644 index b427946805..0000000000 --- a/plugins/nomad/src/components/GroupList/GroupListTable.tsx +++ /dev/null @@ -1,47 +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 { Table, TableColumn } from '@backstage/core-components'; -import React from 'react'; -import { Allocation } from '../../api'; - -const columns: TableColumn[] = [ - { - title: 'Allocation ID', - field: 'ID', - }, - { - title: 'Name', - field: 'Name', - }, -]; - -export const GroupListTable = ({ - allocations, -}: { - allocations: Allocation[]; -}) => { - return ( -
- ); -}; From b1ba5f8f28eadf6150b934e372ec485d300458be Mon Sep 17 00:00:00 2001 From: josh Date: Sun, 4 Jun 2023 11:46:43 -0400 Subject: [PATCH 08/34] Conslidate into one refreshing table Signed-off-by: josh --- plugins/nomad-backend/src/service/router.ts | 56 +++--- plugins/nomad/src/Router.tsx | 14 +- plugins/nomad/src/api.ts | 61 ++++++ .../AllocationList/AllocationListTable.tsx | 182 ++++++++++++++++++ .../GroupList/AllocationListTable.tsx | 96 --------- .../GroupList/GroupListForEntity.tsx | 71 ------- 6 files changed, 287 insertions(+), 193 deletions(-) create mode 100644 plugins/nomad/src/components/AllocationList/AllocationListTable.tsx delete mode 100644 plugins/nomad/src/components/GroupList/AllocationListTable.tsx delete mode 100644 plugins/nomad/src/components/GroupList/GroupListForEntity.tsx 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 ( - - ); -}; From 285de6291402c4d675b344ed4a38eaf0cf2a8bc7 Mon Sep 17 00:00:00 2001 From: josh Date: Sun, 4 Jun 2023 14:48:59 -0400 Subject: [PATCH 09/34] Add job versions card Signed-off-by: josh --- packages/app/src/App.tsx | 2 - .../app/src/components/catalog/EntityPage.tsx | 14 +- plugins/nomad-backend/src/service/router.ts | 78 ++++++---- plugins/nomad/src/Router.tsx | 12 +- plugins/nomad/src/api.ts | 55 +++---- .../EntityNomadAllocationListTable.tsx} | 31 +++- .../EntityNomadJobVersionListCard.tsx | 146 ++++++++++++++++++ .../NomadComponent/NomadComponent.test.tsx | 42 ----- .../NomadComponent/NomadComponent.tsx | 53 ------- .../src/components/NomadComponent/index.ts | 16 -- .../NomadFetchComponent.test.tsx | 40 ----- .../NomadFetchComponent.tsx | 111 ------------- .../{NomadFetchComponent => }/index.ts | 4 +- plugins/nomad/src/index.ts | 9 +- plugins/nomad/src/plugin.ts | 19 --- 15 files changed, 269 insertions(+), 363 deletions(-) rename plugins/nomad/src/components/{AllocationList/AllocationListTable.tsx => EntityNomadAllocationListTable/EntityNomadAllocationListTable.tsx} (87%) create mode 100644 plugins/nomad/src/components/EntityNomadJobVersionListCard/EntityNomadJobVersionListCard.tsx delete mode 100644 plugins/nomad/src/components/NomadComponent/NomadComponent.test.tsx delete mode 100644 plugins/nomad/src/components/NomadComponent/NomadComponent.tsx delete mode 100644 plugins/nomad/src/components/NomadComponent/index.ts delete mode 100644 plugins/nomad/src/components/NomadFetchComponent/NomadFetchComponent.test.tsx delete mode 100644 plugins/nomad/src/components/NomadFetchComponent/NomadFetchComponent.tsx rename plugins/nomad/src/components/{NomadFetchComponent => }/index.ts (79%) diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 9a9191afc5..df54f37dc6 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -110,7 +110,6 @@ import { ScoreBoardPage } from '@oriflame/backstage-plugin-score-card'; import { StackstormPage } from '@backstage/plugin-stackstorm'; import { PuppetDbPage } from '@backstage/plugin-puppetdb'; import { DevToolsPage } from '@backstage/plugin-devtools'; -import { NomadPage } from '@backstage/plugin-nomad'; import { customDevToolsPage } from './components/devtools/CustomDevToolsPage'; import { CatalogUnprocessedEntitiesPage } from '@backstage/plugin-catalog-unprocessed-entities'; @@ -303,7 +302,6 @@ const routes = ( }> {customDevToolsPage} - } /> ); diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index f6950f5c62..043a9fc3aa 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -112,7 +112,10 @@ import { EntityOwnershipCard, EntityUserProfileCard, } from '@backstage/plugin-org'; -import { EntityNomadContent } from '@backstage/plugin-nomad'; +import { + EntityNomadContent, + EntityNomadJobVersionListCard, +} from '@backstage/plugin-nomad'; import { EntityPagerDutyCard, isPagerDutyAvailable, @@ -173,6 +176,7 @@ import { isLinguistAvailable, EntityLinguistCard, } from '@backstage/plugin-linguist'; +import { isNomadJobIDAvailable } from '@backstage/plugin-nomad'; const customEntityFilterKind = ['Component', 'API', 'System']; @@ -443,6 +447,14 @@ const overviewContent = ( + + + + + + + + ); diff --git a/plugins/nomad-backend/src/service/router.ts b/plugins/nomad-backend/src/service/router.ts index 161471f63a..aa863e3493 100644 --- a/plugins/nomad-backend/src/service/router.ts +++ b/plugins/nomad-backend/src/service/router.ts @@ -16,21 +16,32 @@ import { errorHandler, requestLoggingHandler } from '@backstage/backend-common'; import { Config } from '@backstage/config'; import { InputError } from '@backstage/errors'; -import express, { RequestHandler } from 'express'; +import express from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; -const createEndpoint = ( +export interface RouterOptions { + logger: Logger; + config: Config; +} + +export async function createRouter( options: RouterOptions, - endpoint: string, -): RequestHandler => { +): Promise { const { config, logger } = options; // Get Nomad addr and token from config const addr = config.getString('nomad.addr'); const token = config.getOptionalString('nomad.token'); - return async function (req, resp) { + const router = Router(); + router.use(express.json()); + + router.get('/health', (_, resp) => { + resp.json({ status: 'ok' }); + }); + + router.get('/v1/allocations', async (req, resp) => { // Check namespace argument const namespace = (req.query.namespace as string) ?? ''; if (!namespace || namespace === '') { @@ -39,12 +50,7 @@ const createEndpoint = ( // Check filter argument const filter = (req.query.filter as string) ?? ''; - if (!filter || filter === '') { - throw new InputError(`Missing "filter" query parameter`); - } - logger.debug( - `${endpoint} request headers: namespace=${namespace} filter=${filter}`, - ); + logger.debug(`request headers: namespace=${namespace} filter=${filter}`); // Issue the request const allocationsResp = await fetch( @@ -62,27 +68,39 @@ const createEndpoint = ( // Deserialize and return const allocationsBody = await allocationsResp.json(); - logger.debug(`${endpoint} response: ${allocationsBody}`); + logger.debug(`/v1/allocations 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.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) ?? ''; + + // Issue the request + const versionsResp = await fetch( + `${addr}/v1/job/${jobID}/versions?namespace=${encodeURIComponent( + namespace, + )}`, + { + method: 'GET', + headers: { + Accept: 'application/json', + 'X-Nomad-Token': token || '', + }, + }, + ); + + // Deserialize and return + const versionsBody = await versionsResp.json(); + logger.debug(`/v1/job/:job_id/versions response: ${versionsBody}`); + resp.json(versionsBody); + }); router.use(requestLoggingHandler()); router.use(errorHandler()); diff --git a/plugins/nomad/src/Router.tsx b/plugins/nomad/src/Router.tsx index 11c79c31d7..dadeda86db 100644 --- a/plugins/nomad/src/Router.tsx +++ b/plugins/nomad/src/Router.tsx @@ -19,7 +19,7 @@ import { Entity } from '@backstage/catalog-model'; import { useEntity } from '@backstage/plugin-catalog-react'; import { MissingAnnotationEmptyState } from '@backstage/core-components'; import { Route, Routes } from 'react-router-dom'; -import { AllocationListTable } from './components/AllocationList/AllocationListTable'; +import { EntityNomadAllocationListTable } from './components/EntityNomadAllocationListTable/EntityNomadAllocationListTable'; /** @public */ export const NOMAD_NAMESPACE_ANNOTATION = 'nomad.io/namespace'; @@ -31,7 +31,11 @@ export const NOMAD_JOB_ID_ANNOTATION = 'nomad.io/job-id'; export const NOMAD_GROUP_ANNOTATION = 'nomad.io/group'; /** @public */ -export const isNomadAvailable = (entity: Entity) => +export const isNomadJobIDAvailable = (entity: Entity) => + Boolean(entity.metadata.annotations?.[NOMAD_JOB_ID_ANNOTATION]); + +/** @public */ +export const isNomadAllocationsAvailable = (entity: Entity) => Boolean(entity.metadata.annotations?.[NOMAD_JOB_ID_ANNOTATION]) || Boolean(entity.metadata.annotations?.[NOMAD_GROUP_ANNOTATION]); @@ -39,7 +43,7 @@ export const isNomadAvailable = (entity: Entity) => export const EmbeddedRouter = () => { const { entity } = useEntity(); - if (!isNomadAvailable(entity)) { + if (!isNomadAllocationsAvailable(entity)) { return ( { return ( - } /> + } /> ); }; diff --git a/plugins/nomad/src/api.ts b/plugins/nomad/src/api.ts index 0cdba4a969..814bba5b6d 100644 --- a/plugins/nomad/src/api.ts +++ b/plugins/nomad/src/api.ts @@ -37,13 +37,13 @@ export type NomadApi = { ) => Promise; /** - * listDeployments is for listing all deployments matching some part of 'filter'. + * listJobVersions is for listing all deployments matching some part of 'filter'. * * See: https://developer.hashicorp.com/nomad/api-docs/deployments#list-deployments */ - listDeployments: ( - options: ListDeploymentsRequest, - ) => Promise; + listJobVersions: ( + options: ListJobVersionsRequest, + ) => Promise; }; /** @public */ @@ -79,37 +79,22 @@ export interface DeploymentStatus { } /** @public */ -export interface ListDeploymentsRequest { +export interface ListJobVersionsRequest { namespace: string; - filter: string; + jobID: string; } /** @public */ -export interface ListDeploymentsResponse { - deployments: Deployment[]; +export interface ListJobVersionsResponse { + versions: Version[]; } /** @public */ -export interface Deployment { +export interface Version { ID: string; - JobID: string; - JobVersion: number; - JobModifyIndex: number; - TaskGroups: { - [taskGroup: string]: { - Promoted: boolean; - DesiredCanaries: number; - DesiredTotal: number; - PlacedAllocs: number; - HealthyAllocs: number; - UnhealthyAllocs: number; - }; - }; - JobSpecModifyIndex: number; - JobCreateIndex: number; - Status: string; - CreateIndex: number; - ModifyIndex: number; + SubmitTime: number; + Stable: boolean; + Version: number; } /** @public */ @@ -154,20 +139,22 @@ export class NomadHttpApi implements NomadApi { } // TODO: pagination - async listDeployments( - options: ListDeploymentsRequest, - ): Promise { + async listJobVersions( + options: ListJobVersionsRequest, + ): 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)}`, + `${apiUrl}/v1/job/${ + options.jobID + }/versions?namespace=${encodeURIComponent(options.namespace)}`, ); if (!resp.ok) throw await FetchError.forResponse(resp); + const respJson = await resp.json(); + return Promise.resolve({ - deployments: await resp.json(), + versions: respJson.Versions, }); } } diff --git a/plugins/nomad/src/components/AllocationList/AllocationListTable.tsx b/plugins/nomad/src/components/EntityNomadAllocationListTable/EntityNomadAllocationListTable.tsx similarity index 87% rename from plugins/nomad/src/components/AllocationList/AllocationListTable.tsx rename to plugins/nomad/src/components/EntityNomadAllocationListTable/EntityNomadAllocationListTable.tsx index 9130eb4007..d07563dd6b 100644 --- a/plugins/nomad/src/components/AllocationList/AllocationListTable.tsx +++ b/plugins/nomad/src/components/EntityNomadAllocationListTable/EntityNomadAllocationListTable.tsx @@ -16,6 +16,7 @@ import { Link, + MissingAnnotationEmptyState, ResponseErrorPanel, StatusError, StatusOK, @@ -32,6 +33,7 @@ import { NOMAD_GROUP_ANNOTATION, NOMAD_JOB_ID_ANNOTATION, NOMAD_NAMESPACE_ANNOTATION, + isNomadAllocationsAvailable, } from '../../Router'; import useAsync from 'react-use/lib/useAsync'; @@ -92,13 +94,14 @@ const columns: TableColumn[] = [ ]; /** - * AllocationListTable is roughly based off Nomad's Allocations tab's view. + * EntityNomadAllocationListTable is roughly based off Nomad's Allocations tab's view. */ -export const AllocationListTable = () => { +export const EntityNomadAllocationListTable = () => { // Wait on entity const { entity } = useEntity(); // Get ref to the backend API + const [init, setInit] = useState(true); const configApi = useApi(configApiRef); const nomadApi = useApi(nomadApiRef); const nomadAddr = configApi.getString('nomad.addr'); @@ -107,6 +110,13 @@ export const AllocationListTable = () => { const [allocations, setAllocations] = useState([]); const [err, setErr] = useState(); + // Check that attributes are available + if (!isNomadAllocationsAvailable(entity)) { + ; + } + // Get plugin attributes const namespace = entity.metadata.annotations?.[NOMAD_NAMESPACE_ANNOTATION] ?? 'default'; @@ -137,10 +147,8 @@ export const AllocationListTable = () => { .sort(({ ClientStatus: a }, { ClientStatus: b }) => { if (a === 'running' || b !== 'running') { return -1; - } else if (a === b || a !== 'running') { - return 0; } - return 1; + return 0; }); setAllocations(results.map(row => ({ ...row, id: row.ID, nomadAddr }))); @@ -148,14 +156,21 @@ export const AllocationListTable = () => { } catch (e) { setAllocations([]); setErr(e); - return; } }; // Start querying for allocations every 5s useAsync(async () => { - query(); - return () => clearInterval(setInterval(query, 5000)); + if (init) { + setInit(false); + query(); + } + + const interval = setTimeout(() => { + query(); + }, 5_000); + + return () => clearTimeout(interval); }, [allocations, entity]); // Store a ref to a potential error diff --git a/plugins/nomad/src/components/EntityNomadJobVersionListCard/EntityNomadJobVersionListCard.tsx b/plugins/nomad/src/components/EntityNomadJobVersionListCard/EntityNomadJobVersionListCard.tsx new file mode 100644 index 0000000000..909fd7a236 --- /dev/null +++ b/plugins/nomad/src/components/EntityNomadJobVersionListCard/EntityNomadJobVersionListCard.tsx @@ -0,0 +1,146 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + InfoCard, + MissingAnnotationEmptyState, + ResponseErrorPanel, + Table, + TableColumn, +} from '@backstage/core-components'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import React, { useEffect, useState } from 'react'; +import { Version, nomadApiRef } from '../../api'; +import { configApiRef, useApi } from '@backstage/core-plugin-api'; +import { + NOMAD_JOB_ID_ANNOTATION, + NOMAD_NAMESPACE_ANNOTATION, + isNomadJobIDAvailable, +} from '../../Router'; +import OpenInNewIcon from '@material-ui/icons/OpenInNew'; +import { Chip } from '@material-ui/core'; + +type rowType = Version & { nomadAddr: string }; + +const columns: TableColumn[] = [ + { + title: 'Version', + field: 'Version', + render: row => row.Version, + }, + { + title: 'Stable', + field: 'Stable', + render: row => , + }, + { + title: 'Submitted', + field: 'SubmitTime', + render: row => new Date(row.SubmitTime / 1000000).toLocaleString(), + }, +]; + +/** + * EntityNomadJobVersionListCard is roughly based on the Nomad UI's versions tab. + */ +export const EntityNomadJobVersionListCard = () => { + // Wait on entity + const { entity } = useEntity(); + + // Get ref to the backend API + const configApi = useApi(configApiRef); + const nomadApi = useApi(nomadApiRef); + const nomadAddr = configApi.getString('nomad.addr'); + + // Store results of calling API + const [init, setInit] = useState(true); + const [versions, setVersions] = useState([]); + 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 ; + } + + // Check that job ID is set + if (!isNomadJobIDAvailable(entity)) { + return ( + + + + ); + } + + return ( + + title="Job Versions" + actions={[ + { + icon: () => , + tooltip: 'Open Job Versions Tab in Nomad', + isFreeAction: true, + onClick: () => window.open(`${nomadAddr}/ui/jobs/${jobID}/versions`), + }, + ]} + options={{ + search: false, + padding: 'dense', + sorting: true, + draggable: false, + paging: false, + debounceInterval: 500, + filterCellStyle: { padding: '0 16px 0 20px' }, + }} + columns={columns} + data={versions} + /> + ); +}; diff --git a/plugins/nomad/src/components/NomadComponent/NomadComponent.test.tsx b/plugins/nomad/src/components/NomadComponent/NomadComponent.test.tsx deleted file mode 100644 index 31b7319fd6..0000000000 --- a/plugins/nomad/src/components/NomadComponent/NomadComponent.test.tsx +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright 2023 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React from 'react'; -import { NomadComponent } from './NomadComponent'; -import { rest } from 'msw'; -import { setupServer } from 'msw/node'; -import { screen } from '@testing-library/react'; -import { - setupRequestMockHandlers, - renderInTestApp, -} from '@backstage/test-utils'; - -describe('NomadComponent', () => { - const server = setupServer(); - // Enable sane handlers for network requests - setupRequestMockHandlers(server); - - // setup mock response - beforeEach(() => { - server.use( - rest.get('/*', (_, res, ctx) => res(ctx.status(200), ctx.json({}))), - ); - }); - - it('should render', async () => { - await renderInTestApp(); - expect(screen.getByText('Welcome to nomad!')).toBeInTheDocument(); - }); -}); diff --git a/plugins/nomad/src/components/NomadComponent/NomadComponent.tsx b/plugins/nomad/src/components/NomadComponent/NomadComponent.tsx deleted file mode 100644 index cd7334d7eb..0000000000 --- a/plugins/nomad/src/components/NomadComponent/NomadComponent.tsx +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright 2023 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React from 'react'; -import { Typography, Grid } from '@material-ui/core'; -import { - InfoCard, - Header, - Page, - Content, - ContentHeader, - HeaderLabel, - SupportButton, -} from '@backstage/core-components'; -import { NomadFetchComponent } from '../NomadFetchComponent'; - -export const NomadComponent = () => ( - -
- - -
- - - A description of your plugin goes here. - - - - - - All content should be wrapped in a card like this. - - - - - - - - -
-); diff --git a/plugins/nomad/src/components/NomadComponent/index.ts b/plugins/nomad/src/components/NomadComponent/index.ts deleted file mode 100644 index aeecd0d9e6..0000000000 --- a/plugins/nomad/src/components/NomadComponent/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright 2023 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -export { NomadComponent } from './NomadComponent'; diff --git a/plugins/nomad/src/components/NomadFetchComponent/NomadFetchComponent.test.tsx b/plugins/nomad/src/components/NomadFetchComponent/NomadFetchComponent.test.tsx deleted file mode 100644 index 48c27dce05..0000000000 --- a/plugins/nomad/src/components/NomadFetchComponent/NomadFetchComponent.test.tsx +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright 2023 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import { NomadFetchComponent } from './NomadFetchComponent'; -import { rest } from 'msw'; -import { setupServer } from 'msw/node'; -import { setupRequestMockHandlers } from '@backstage/test-utils'; - -describe('NomadFetchComponent', () => { - const server = setupServer(); - // Enable sane handlers for network requests - setupRequestMockHandlers(server); - - // setup mock response - beforeEach(() => { - server.use( - rest.get('https://randomuser.me/*', (_, res, ctx) => - res(ctx.status(200), ctx.delay(2000), ctx.json({})), - ), - ); - }); - it('should render', async () => { - await render(); - expect(await screen.findByTestId('progress')).toBeInTheDocument(); - }); -}); diff --git a/plugins/nomad/src/components/NomadFetchComponent/NomadFetchComponent.tsx b/plugins/nomad/src/components/NomadFetchComponent/NomadFetchComponent.tsx deleted file mode 100644 index 0cd3061a3e..0000000000 --- a/plugins/nomad/src/components/NomadFetchComponent/NomadFetchComponent.tsx +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright 2023 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React from 'react'; -import { makeStyles } from '@material-ui/core/styles'; -import { - Table, - TableColumn, - Progress, - ResponseErrorPanel, -} from '@backstage/core-components'; -import { fetchApiRef, useApi } from '@backstage/core-plugin-api'; -import useAsync from 'react-use/lib/useAsync'; - -const useStyles = makeStyles({ - avatar: { - height: 32, - width: 32, - borderRadius: '50%', - }, -}); - -type User = { - gender: string; // "male" - name: { - title: string; // "Mr", - first: string; // "Duane", - last: string; // "Reed" - }; - location: object; // {street: {number: 5060, name: "Hickory Creek Dr"}, city: "Albany", state: "New South Wales",…} - email: string; // "duane.reed@example.com" - login: object; // {uuid: "4b785022-9a23-4ab9-8a23-cb3fb43969a9", username: "blackdog796", password: "patch",…} - dob: object; // {date: "1983-06-22T12:30:23.016Z", age: 37} - registered: object; // {date: "2006-06-13T18:48:28.037Z", age: 14} - phone: string; // "07-2154-5651" - cell: string; // "0405-592-879" - id: { - name: string; // "TFN", - value: string; // "796260432" - }; - picture: { medium: string }; // {medium: "https://randomuser.me/api/portraits/men/95.jpg",…} - nat: string; // "AU" -}; - -type DenseTableProps = { - users: User[]; -}; - -export const DenseTable = ({ users }: DenseTableProps) => { - const classes = useStyles(); - - const columns: TableColumn[] = [ - { title: 'Avatar', field: 'avatar' }, - { title: 'Name', field: 'name' }, - { title: 'Email', field: 'email' }, - { title: 'Nationality', field: 'nationality' }, - ]; - - const data = users.map(user => { - return { - avatar: ( - {user.name.first} - ), - name: `${user.name.first} ${user.name.last}`, - email: user.email, - nationality: user.nat, - }; - }); - - return ( -
- ); -}; - -export const NomadFetchComponent = () => { - const { fetch } = useApi(fetchApiRef); - const { value, loading, error } = useAsync(async (): Promise => { - const response = await fetch('https://randomuser.me/api/?results=20'); - const data = await response.json(); - return data.results; - }, []); - - if (loading) { - return ; - } else if (error) { - return ; - } - - return ; -}; diff --git a/plugins/nomad/src/components/NomadFetchComponent/index.ts b/plugins/nomad/src/components/index.ts similarity index 79% rename from plugins/nomad/src/components/NomadFetchComponent/index.ts rename to plugins/nomad/src/components/index.ts index 8e30a21fd1..d113bf73d9 100644 --- a/plugins/nomad/src/components/NomadFetchComponent/index.ts +++ b/plugins/nomad/src/components/index.ts @@ -13,4 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { NomadFetchComponent } from './NomadFetchComponent'; +import { EntityNomadJobVersionListCard } from './EntityNomadJobVersionListCard/EntityNomadJobVersionListCard'; + +export { EntityNomadJobVersionListCard }; diff --git a/plugins/nomad/src/index.ts b/plugins/nomad/src/index.ts index 59ae5efaee..601c133b52 100644 --- a/plugins/nomad/src/index.ts +++ b/plugins/nomad/src/index.ts @@ -13,5 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { nomadPlugin, NomadPage, EntityNomadContent } from './plugin'; -export { isNomadAvailable, EmbeddedRouter } from './Router'; +export { nomadPlugin } from './plugin'; +export { + isNomadAllocationsAvailable, + isNomadJobIDAvailable, + EmbeddedRouter, +} from './Router'; +export { EntityNomadJobVersionListCard } from './components'; diff --git a/plugins/nomad/src/plugin.ts b/plugins/nomad/src/plugin.ts index 1b45e803b4..65181724d5 100644 --- a/plugins/nomad/src/plugin.ts +++ b/plugins/nomad/src/plugin.ts @@ -44,22 +44,3 @@ export const nomadPlugin = createPlugin({ entityContent: entityContentRouteRef, }, }); - -/** @public */ -export const NomadPage = nomadPlugin.provide( - createRoutableExtension({ - name: 'NomadPage', - component: () => - import('./components/NomadComponent').then(m => m.NomadComponent), - mountPoint: rootRouteRef, - }), -); - -/** @public */ -export const EntityNomadContent = nomadPlugin.provide( - createRoutableExtension({ - name: 'EntityNomadContent', - component: () => import('./Router').then(m => m.EmbeddedRouter), - mountPoint: entityContentRouteRef, - }), -); From 523af24224d12893c4c8f374cd92669b69affa3f Mon Sep 17 00:00:00 2001 From: josh Date: Sun, 4 Jun 2023 15:30:32 -0400 Subject: [PATCH 10/34] Fix registration of component Signed-off-by: josh --- .../app/src/components/catalog/EntityPage.tsx | 15 ++++--- plugins/nomad-backend/src/run.ts | 4 +- .../EntityNomadJobVersionListCard.tsx | 6 ++- plugins/nomad/src/components/index.ts | 3 +- plugins/nomad/src/index.ts | 7 +++- plugins/nomad/src/plugin.ts | 42 ++++++++++++++++++- 6 files changed, 65 insertions(+), 12 deletions(-) diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 043a9fc3aa..be5ae3e3bf 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -113,8 +113,10 @@ import { EntityUserProfileCard, } from '@backstage/plugin-org'; import { - EntityNomadContent, + EntityNomadAllocationListTable, EntityNomadJobVersionListCard, + isNomadAllocationsAvailable, + isNomadJobIDAvailable, } from '@backstage/plugin-nomad'; import { EntityPagerDutyCard, @@ -176,7 +178,6 @@ import { isLinguistAvailable, EntityLinguistCard, } from '@backstage/plugin-linguist'; -import { isNomadJobIDAvailable } from '@backstage/plugin-nomad'; const customEntityFilterKind = ['Component', 'API', 'System']; @@ -450,7 +451,7 @@ const overviewContent = ( - + @@ -514,8 +515,12 @@ const serviceEntityPage = ( - - + + diff --git a/plugins/nomad-backend/src/run.ts b/plugins/nomad-backend/src/run.ts index d299ed23e9..711bdcc46c 100644 --- a/plugins/nomad-backend/src/run.ts +++ b/plugins/nomad-backend/src/run.ts @@ -16,12 +16,14 @@ 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({ port, enableCors, logger }).catch(err => { +startStandaloneServer({ config, port, enableCors, logger }).catch(err => { logger.error(err); process.exit(1); }); diff --git a/plugins/nomad/src/components/EntityNomadJobVersionListCard/EntityNomadJobVersionListCard.tsx b/plugins/nomad/src/components/EntityNomadJobVersionListCard/EntityNomadJobVersionListCard.tsx index 909fd7a236..c4dad1f455 100644 --- a/plugins/nomad/src/components/EntityNomadJobVersionListCard/EntityNomadJobVersionListCard.tsx +++ b/plugins/nomad/src/components/EntityNomadJobVersionListCard/EntityNomadJobVersionListCard.tsx @@ -110,6 +110,8 @@ export const EntityNomadJobVersionListCard = () => { return ; } + if (!entity) return <>; + // Check that job ID is set if (!isNomadJobIDAvailable(entity)) { return ( @@ -121,11 +123,11 @@ export const EntityNomadJobVersionListCard = () => { return ( - title="Job Versions" + title="Job versions" actions={[ { icon: () => , - tooltip: 'Open Job Versions Tab in Nomad', + tooltip: 'Open in Nomad UI', isFreeAction: true, onClick: () => window.open(`${nomadAddr}/ui/jobs/${jobID}/versions`), }, diff --git a/plugins/nomad/src/components/index.ts b/plugins/nomad/src/components/index.ts index d113bf73d9..3951061b23 100644 --- a/plugins/nomad/src/components/index.ts +++ b/plugins/nomad/src/components/index.ts @@ -14,5 +14,6 @@ * limitations under the License. */ import { EntityNomadJobVersionListCard } from './EntityNomadJobVersionListCard/EntityNomadJobVersionListCard'; +import { EntityNomadAllocationListTable } from './EntityNomadAllocationListTable/EntityNomadAllocationListTable'; -export { EntityNomadJobVersionListCard }; +export { EntityNomadJobVersionListCard, EntityNomadAllocationListTable }; diff --git a/plugins/nomad/src/index.ts b/plugins/nomad/src/index.ts index 601c133b52..2b95af217a 100644 --- a/plugins/nomad/src/index.ts +++ b/plugins/nomad/src/index.ts @@ -13,10 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { nomadPlugin } from './plugin'; +export { + nomadPlugin, + EntityNomadJobVersionListCard, + EntityNomadAllocationListTable, +} from './plugin'; export { isNomadAllocationsAvailable, isNomadJobIDAvailable, EmbeddedRouter, } from './Router'; -export { EntityNomadJobVersionListCard } from './components'; diff --git a/plugins/nomad/src/plugin.ts b/plugins/nomad/src/plugin.ts index 65181724d5..d8367d97df 100644 --- a/plugins/nomad/src/plugin.ts +++ b/plugins/nomad/src/plugin.ts @@ -18,8 +18,9 @@ import { fetchApiRef, createApiFactory, createPlugin, - createRoutableExtension, createRouteRef, + createRoutableExtension, + createComponentExtension, } from '@backstage/core-plugin-api'; import { rootRouteRef } from './routes'; @@ -44,3 +45,42 @@ export const nomadPlugin = createPlugin({ 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), + }, + }), +); From 0738aa35686706e36721a8a700a647d33c55ba21 Mon Sep 17 00:00:00 2001 From: josh Date: Sun, 4 Jun 2023 15:36:16 -0400 Subject: [PATCH 11/34] Fix codeowners Signed-off-by: josh --- .github/CODEOWNERS | 162 +++++++++++++++++++++++---------------------- 1 file changed, 82 insertions(+), 80 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a3756e437c..33097b505a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -4,83 +4,85 @@ # The last matching pattern takes precedence. # https://help.github.com/articles/about-codeowners/ -* @backstage/maintainers -*/yarn.lock @backstage/maintainers @backstage-service -/cypress/src/integration/plugins/techdocs.spec.ts @backstage/techdocs-maintainers -/docs/assets/search @backstage/discoverability-maintainers -/docs/features/search @backstage/discoverability-maintainers -/docs/features/techdocs @backstage/techdocs-maintainers -/docs/plugins/integrating-search-into-plugins.md @backstage/discoverability-maintainers -/packages/cli/src/commands/onboard @backstage/sharks -/packages/techdocs-cli @backstage/techdocs-maintainers -/packages/techdocs-cli-embedded-app @backstage/techdocs-maintainers -/plugins/adr @backstage/maintainers @kuangp -/plugins/adr-* @backstage/maintainers @kuangp -/plugins/allure @backstage/maintainers @deepak-bhardwaj-ps -/plugins/apache-airflow @backstage/maintainers @cmpadden -/plugins/api-docs @backstage/maintainers @backstage/sda-se-reviewers -/plugins/azure-devops @backstage/maintainers @marleypowell @awanlin -/plugins/azure-devops-backend @backstage/maintainers @marleypowell @awanlin -/plugins/azure-devops-common @backstage/maintainers @marleypowell @awanlin -/plugins/bitbucket-cloud-common @backstage/maintainers @pjungermann -/plugins/bitrise @backstage/maintainers @backstage/sda-se-reviewers -/plugins/catalog @backstage/maintainers @backstage/catalog-maintainers -/plugins/catalog-* @backstage/maintainers @backstage/catalog-maintainers -/plugins/catalog-backend-module-aws @backstage/maintainers @backstage/catalog-maintainers @pjungermann -/plugins/catalog-backend-module-bitbucket-cloud @backstage/maintainers @backstage/catalog-maintainers @pjungermann -/plugins/catalog-backend-module-msgraph @backstage/maintainers @backstage/catalog-maintainers @pjungermann -/plugins/catalog-backend-module-puppetdb @backstage/maintainers @backstage/catalog-maintainers @tdabasinskas -/plugins/catalog-graph @backstage/maintainers @backstage/catalog-maintainers @backstage/sda-se-reviewers -/plugins/circleci @backstage/maintainers @adamdmharvey -/plugins/cloudbuild @backstage/maintainers @trivago/ebarrios -/plugins/code-coverage @backstage/maintainers @alde @nissayeva -/plugins/code-coverage-backend @backstage/maintainers @alde @nissayeva -/plugins/cost-insights @backstage/maintainers @backstage/silver-lining -/plugins/cost-insights-* @backstage/maintainers @backstage/silver-lining -/plugins/entity-feedback @backstage/maintainers @kuangp -/plugins/entity-feedback-* @backstage/maintainers @kuangp -/plugins/events-backend @backstage/maintainers @pjungermann -/plugins/events-backend-module-aws-sqs @backstage/maintainers @pjungermann -/plugins/events-backend-module-azure @backstage/maintainers @pjungermann -/plugins/events-backend-module-bitbucket-cloud @backstage/maintainers @pjungermann -/plugins/events-backend-module-gerrit @backstage/maintainers @pjungermann -/plugins/events-backend-module-github @backstage/maintainers @pjungermann -/plugins/events-backend-module-gitlab @backstage/maintainers @pjungermann -/plugins/events-backend-test-utils @backstage/maintainers @pjungermann -/plugins/events-node @backstage/maintainers @pjungermann -/plugins/explore @backstage/maintainers @backstage/sda-se-reviewers -/plugins/explore-react @backstage/maintainers @backstage/sda-se-reviewers -/plugins/fossa @backstage/maintainers @backstage/sda-se-reviewers -/plugins/gcalendar @backstage/maintainers @szubster @ptychu @kielosz @alexrybch -/plugins/git-release-manager @backstage/maintainers @erikengervall -/plugins/home @backstage/discoverability-maintainers -/plugins/home-* @backstage/discoverability-maintainers -/plugins/ilert @backstage/maintainers @yacut -/plugins/jenkins @backstage/maintainers @timja -/plugins/jenkins-backend @backstage/maintainers @timja -/plugins/kafka @backstage/maintainers @nirga @andrewthauer -/plugins/kafka-backend @backstage/maintainers @nirga @andrewthauer -/plugins/kubernetes @backstage/maintainers @backstage/kubernetes-maintainers -/plugins/kubernetes-* @backstage/maintainers @backstage/kubernetes-maintainers -/plugins/microsoft-calendar @backstage/maintainers @abhay-soni-developer @NishkarshRaj -/plugins/newrelic-dashboard @backstage/maintainers @mufaddal7 -/plugins/playlist @backstage/maintainers @kuangp -/plugins/playlist-* @backstage/maintainers @kuangp -/plugins/puppetdb @backstage/maintainers @tdabasinskas -/plugins/rollbar @backstage/maintainers @andrewthauer -/plugins/rollbar-backend @backstage/maintainers @andrewthauer -/plugins/scaffolder-backend-module-rails @backstage/maintainers @angeliski -/plugins/scaffolder-backend-module-yeoman @backstage/maintainers @pawelmitka -/plugins/search @backstage/discoverability-maintainers -/plugins/search-* @backstage/discoverability-maintainers -/plugins/sonarqube @backstage/maintainers @backstage/sda-se-reviewers -/plugins/stack-overflow @backstage/discoverability-maintainers -/plugins/stack-overflow-backend @backstage/discoverability-maintainers -/plugins/techdocs @backstage/techdocs-maintainers -/plugins/techdocs-* @backstage/techdocs-maintainers -/plugins/user-settings-backend @backstage/maintainers @backstage/sda-se-reviewers -/tech-insights-backend @backstage/maintainers @xantier @iain-b -/tech-insights-backend-module-jsonfc @backstage/maintainers @xantier @iain-b -/tech-insights-tech-insights-common @backstage/maintainers @xantier @iain-b -/tech-insights-tech-insights-node @backstage/maintainers @xantier @iain-b -yarn.lock @backstage/maintainers @backstage-service +* @backstage/maintainers +yarn.lock @backstage/maintainers @backstage-service +*/yarn.lock @backstage/maintainers @backstage-service +/.changeset/*.md +/cypress/src/integration/plugins/techdocs.spec.ts @backstage/techdocs-maintainers +/docs/assets/search @backstage/discoverability-maintainers +/docs/features/search @backstage/discoverability-maintainers +/docs/features/techdocs @backstage/techdocs-maintainers +/docs/plugins/integrating-search-into-plugins.md @backstage/discoverability-maintainers +/packages/cli/src/commands/onboard @backstage/sharks +/packages/techdocs-cli @backstage/techdocs-maintainers +/packages/techdocs-cli-embedded-app @backstage/techdocs-maintainers +/plugins/adr @backstage/maintainers @kuangp +/plugins/adr-* @backstage/maintainers @kuangp +/plugins/allure @backstage/maintainers @deepak-bhardwaj-ps +/plugins/apache-airflow @backstage/maintainers @cmpadden +/plugins/api-docs @backstage/maintainers @backstage/sda-se-reviewers +/plugins/azure-devops @backstage/maintainers @marleypowell @awanlin +/plugins/azure-devops-backend @backstage/maintainers @marleypowell @awanlin +/plugins/azure-devops-common @backstage/maintainers @marleypowell @awanlin +/plugins/bitbucket-cloud-common @backstage/maintainers @pjungermann +/plugins/bitrise @backstage/maintainers @backstage/sda-se-reviewers +/plugins/catalog @backstage/maintainers @backstage/catalog-maintainers +/plugins/catalog-* @backstage/maintainers @backstage/catalog-maintainers +/plugins/catalog-backend-module-aws @backstage/maintainers @backstage/catalog-maintainers @pjungermann +/plugins/catalog-backend-module-bitbucket-cloud @backstage/maintainers @backstage/catalog-maintainers @pjungermann +/plugins/catalog-backend-module-msgraph @backstage/maintainers @backstage/catalog-maintainers @pjungermann +/plugins/catalog-backend-module-puppetdb @backstage/maintainers @backstage/catalog-maintainers @tdabasinskas +/plugins/catalog-graph @backstage/maintainers @backstage/catalog-maintainers @backstage/sda-se-reviewers +/plugins/circleci @backstage/maintainers @adamdmharvey +/plugins/cloudbuild @backstage/maintainers @trivago/ebarrios +/plugins/code-coverage @backstage/maintainers @alde @nissayeva +/plugins/code-coverage-backend @backstage/maintainers @alde @nissayeva +/plugins/cost-insights @backstage/maintainers @backstage/silver-lining +/plugins/cost-insights-* @backstage/maintainers @backstage/silver-lining +/plugins/entity-feedback @backstage/maintainers @kuangp +/plugins/entity-feedback-* @backstage/maintainers @kuangp +/plugins/events-backend @backstage/maintainers @pjungermann +/plugins/events-backend-module-aws-sqs @backstage/maintainers @pjungermann +/plugins/events-backend-module-azure @backstage/maintainers @pjungermann +/plugins/events-backend-module-bitbucket-cloud @backstage/maintainers @pjungermann +/plugins/events-backend-module-gerrit @backstage/maintainers @pjungermann +/plugins/events-backend-module-github @backstage/maintainers @pjungermann +/plugins/events-backend-module-gitlab @backstage/maintainers @pjungermann +/plugins/events-backend-test-utils @backstage/maintainers @pjungermann +/plugins/events-node @backstage/maintainers @pjungermann +/plugins/explore @backstage/maintainers @backstage/sda-se-reviewers +/plugins/explore-react @backstage/maintainers @backstage/sda-se-reviewers +/plugins/fossa @backstage/maintainers @backstage/sda-se-reviewers +/plugins/gcalendar @backstage/maintainers @szubster @ptychu @kielosz @alexrybch +/plugins/git-release-manager @backstage/maintainers @erikengervall +/plugins/home @backstage/discoverability-maintainers +/plugins/home-* @backstage/discoverability-maintainers +/plugins/ilert @backstage/maintainers @yacut +/plugins/jenkins @backstage/maintainers @timja +/plugins/jenkins-backend @backstage/maintainers @timja +/plugins/kafka @backstage/maintainers @nirga @andrewthauer +/plugins/kafka-backend @backstage/maintainers @nirga @andrewthauer +/plugins/kubernetes @backstage/maintainers @backstage/kubernetes-maintainers +/plugins/kubernetes-* @backstage/maintainers @backstage/kubernetes-maintainers +/plugins/microsoft-calendar @backstage/maintainers @abhay-soni-developer @NishkarshRaj +/plugins/newrelic-dashboard @backstage/maintainers @mufaddal7 +/plugins/permission-* @backstage/permission-maintainers +/plugins/playlist @backstage/maintainers @kuangp +/plugins/playlist-* @backstage/maintainers @kuangp +/plugins/puppetdb @backstage/maintainers @tdabasinskas +/plugins/rollbar @backstage/maintainers @andrewthauer +/plugins/rollbar-backend @backstage/maintainers @andrewthauer +/plugins/scaffolder-backend-module-rails @backstage/maintainers @angeliski +/plugins/scaffolder-backend-module-yeoman @backstage/maintainers @pawelmitka +/plugins/search @backstage/discoverability-maintainers +/plugins/search-* @backstage/discoverability-maintainers +/plugins/sonarqube @backstage/maintainers @backstage/sda-se-reviewers +/plugins/stack-overflow @backstage/discoverability-maintainers +/plugins/stack-overflow-backend @backstage/discoverability-maintainers +/plugins/techdocs @backstage/techdocs-maintainers +/plugins/techdocs-* @backstage/techdocs-maintainers +/plugins/user-settings-backend @backstage/maintainers @backstage/sda-se-reviewers +/tech-insights-backend @backstage/maintainers @xantier @iain-b +/tech-insights-backend-module-jsonfc @backstage/maintainers @xantier @iain-b +/tech-insights-tech-insights-common @backstage/maintainers @xantier @iain-b +/tech-insights-tech-insights-node @backstage/maintainers @xantier @iain-b From 520e984280c48af001b08bbcf3dbf8362f7effa9 Mon Sep 17 00:00:00 2001 From: josh Date: Sun, 4 Jun 2023 16:32:58 -0400 Subject: [PATCH 12/34] Init frontend README Signed-off-by: josh --- plugins/nomad/README.md | 119 ++++++++++++++++-- plugins/nomad/img/allocations-component.png | Bin 0 -> 120025 bytes plugins/nomad/img/job-versions-component.png | Bin 0 -> 53458 bytes .../EntityNomadAllocationListTable.tsx | 2 +- 4 files changed, 112 insertions(+), 9 deletions(-) create mode 100644 plugins/nomad/img/allocations-component.png create mode 100644 plugins/nomad/img/job-versions-component.png diff --git a/plugins/nomad/README.md b/plugins/nomad/README.md index 1ec9f3d309..c1fc81886e 100644 --- a/plugins/nomad/README.md +++ b/plugins/nomad/README.md @@ -1,13 +1,116 @@ -# nomad +# @backstage/plugin-nomad -Welcome to the nomad plugin! +This plugin is a frontend for viewing Nomad job versions and allocations. -_This plugin was created through the Backstage CLI_ +## Introduction -## Getting started +### Nomad -Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/nomad](http://localhost:3000/nomad). +> A simple and flexible scheduler and orchestrator to deploy and manage containers and non-containerized applications across on-prem and clouds at scale. -You can also serve the plugin in isolation by running `yarn start` in the plugin directory. -This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads. -It is only meant for local development, and the setup for it can be found inside the [/dev](./dev) directory. +### Features + +At the time of writing, 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 a running Nomad cluster. 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 ACLs are enabled, you will need a `token` with at least [`list-jobs` and `read-jobs` capabilities](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. + +### 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: 70d707b6-3d45-472e-8639-6b15770c19b8 +``` + +The `token` can be excluded if [ACLs are not enabled](https://developer.hashicorp.com/nomad/api-docs#authentication). + +### Annotate Components + +There are two annotations for Components in the Service Catalog: + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + annotations: + nomad.io/job-id: redis + nomad.io/group: 'redis|prometheus-collector' +``` + +The `nomad.io/job-id` annotation's value is matched exactly. The `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). + +### 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 diff --git a/plugins/nomad/img/allocations-component.png b/plugins/nomad/img/allocations-component.png new file mode 100644 index 0000000000000000000000000000000000000000..fb1e9768affc46a78ae45654858636c2f14b72d9 GIT binary patch literal 120025 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

-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 MmYSYw6&e%qKWDmH1ONa4 literal 0 HcmV?d00001 diff --git a/plugins/nomad/img/job-versions-component.png b/plugins/nomad/img/job-versions-component.png new file mode 100644 index 0000000000000000000000000000000000000000..d6c9746bcb58f4740a40d2bebd81d1c874c95e9a GIT binary patch literal 53458 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

M%0!c!hH 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 { // Make filter from attributes const filter: string[] = []; if (job) { - filter.push(`(JobID matches "${job}")`); + filter.push(`(JobID == "${job}")`); } if (group) { filter.push(`(TaskGroup matches "${group}")`); From c0f49cec7737e0c2a0a3af4cbb7ae273d6d59165 Mon Sep 17 00:00:00 2001 From: josh Date: Sun, 4 Jun 2023 18:32:19 -0400 Subject: [PATCH 13/34] Add backend README Signed-off-by: josh --- packages/backend/src/plugins/nomad.ts | 12 +---- plugins/nomad-backend/README.md | 54 +++++++++++++++---- .../nomad-backend/src/service/router.test.ts | 2 + plugins/nomad/README.md | 17 ++++-- plugins/nomad/dev/index.tsx | 12 +---- 5 files changed, 63 insertions(+), 34 deletions(-) diff --git a/packages/backend/src/plugins/nomad.ts b/packages/backend/src/plugins/nomad.ts index 68e4344f98..111cbfbf0d 100644 --- a/packages/backend/src/plugins/nomad.ts +++ b/packages/backend/src/plugins/nomad.ts @@ -18,15 +18,7 @@ import { Router } from 'express'; import { PluginEnvironment } from '../types'; export default async function createPlugin( - env: PluginEnvironment, + props: PluginEnvironment, ): Promise { - // Here is where you will add all of the required initialization code that - // your backend plugin needs to be able to start! - - // The env contains a lot of goodies, but our router currently only - // needs a logger - return await createRouter({ - logger: env.logger, - config: env.config, - }); + return await createRouter(props); } diff --git a/plugins/nomad-backend/README.md b/plugins/nomad-backend/README.md index 1a670bd9a8..3a25df2a72 100644 --- a/plugins/nomad-backend/README.md +++ b/plugins/nomad-backend/README.md @@ -1,14 +1,50 @@ -# nomad +# @backstage/plugin-nomad-backend -Welcome to the nomad backend plugin! +A backend for Nomad, 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. -_This plugin was created through the Backstage CLI_ +## Set Up -## Getting started +1. Install the plugin using: -Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn -start` in the root directory, and then navigating to [/nomad-backend](http://localhost:3000/nomad-backend). +```bash +# From your Backstage root directory +yarn add --cwd packages/backend @backstage/plugin-nomad-backend +``` -You can also serve the plugin in isolation by running `yarn start` in the plugin directory. -This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads. -It is only meant for local development, and the setup for it can be found inside the [/dev](/dev) directory. +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 configured. diff --git a/plugins/nomad-backend/src/service/router.test.ts b/plugins/nomad-backend/src/service/router.test.ts index c10dc8ba0e..d94948cbbd 100644 --- a/plugins/nomad-backend/src/service/router.test.ts +++ b/plugins/nomad-backend/src/service/router.test.ts @@ -18,12 +18,14 @@ 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({}), logger: getVoidLogger(), }); app = express().use(router); diff --git a/plugins/nomad/README.md b/plugins/nomad/README.md index c1fc81886e..ef83b34fe1 100644 --- a/plugins/nomad/README.md +++ b/plugins/nomad/README.md @@ -1,6 +1,8 @@ # @backstage/plugin-nomad -This plugin is a frontend for viewing Nomad job versions and allocations. +This is a frontend plugin is for viewing Nomad [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 @@ -19,9 +21,11 @@ At the time of writing, this plugin provides two components: ### Requirements -You will need a running Nomad cluster. You can follow [this tutorial](https://developer.hashicorp.com/nomad/tutorials/enterprise/production-deployment-guide-vm-with-consul) to learn how to deploy one. +You will need to have the backend Nomad plugin, `@backstage/plugin-nomad-backend`, installed and running. See its README for set up instructions. -If ACLs are enabled, you will need a `token` with at least [`list-jobs` and `read-jobs` capabilities](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. +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 [`list-jobs` and `read-jobs` capabilities](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. ### Installation @@ -44,18 +48,21 @@ The `token` can be excluded if [ACLs are not enabled](https://developer.hashicor ### Annotate Components -There are two annotations for Components in the Service Catalog: +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' ``` -The `nomad.io/job-id` annotation's value is matched exactly. The `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). +- `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 diff --git a/plugins/nomad/dev/index.tsx b/plugins/nomad/dev/index.tsx index 07d294c019..73eaffb8e0 100644 --- a/plugins/nomad/dev/index.tsx +++ b/plugins/nomad/dev/index.tsx @@ -13,15 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; import { createDevApp } from '@backstage/dev-utils'; -import { nomadPlugin, NomadPage } from '../src/plugin'; +import { nomadPlugin } from '../src/plugin'; -createDevApp() - .registerPlugin(nomadPlugin) - .addPage({ - element: , - title: 'Root Page', - path: '/nomad', - }) - .render(); +createDevApp().registerPlugin(nomadPlugin).render(); From 2de03da519f1b9abd6906ae6a4e10ebb7155d188 Mon Sep 17 00:00:00 2001 From: josh Date: Mon, 5 Jun 2023 21:00:29 -0400 Subject: [PATCH 14/34] Add example of a token with a policy to read allocs Signed-off-by: josh --- plugins/nomad-backend/README.md | 2 +- plugins/nomad-backend/src/service/router.ts | 46 +++++++++++++------- plugins/nomad/README.md | 48 ++++++++++++++++++++- 3 files changed, 78 insertions(+), 18 deletions(-) diff --git a/plugins/nomad-backend/README.md b/plugins/nomad-backend/README.md index 3a25df2a72..bb0b48fe4a 100644 --- a/plugins/nomad-backend/README.md +++ b/plugins/nomad-backend/README.md @@ -47,4 +47,4 @@ export default async function createPlugin( + 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 configured. +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/src/service/router.ts b/plugins/nomad-backend/src/service/router.ts index aa863e3493..7c462d811e 100644 --- a/plugins/nomad-backend/src/service/router.ts +++ b/plugins/nomad-backend/src/service/router.ts @@ -41,7 +41,7 @@ export async function createRouter( resp.json({ status: 'ok' }); }); - router.get('/v1/allocations', async (req, resp) => { + router.get('/v1/allocations', async (req, res) => { // Check namespace argument const namespace = (req.query.namespace as string) ?? ''; if (!namespace || namespace === '') { @@ -53,23 +53,29 @@ export async function createRouter( logger.debug(`request headers: namespace=${namespace} filter=${filter}`); // Issue the request - const allocationsResp = await fetch( - `${addr}/v1/allocations?namespace=${encodeURIComponent( - namespace, - )}&filter=${encodeURIComponent(filter)}`, - { - method: 'GET', - headers: { - Accept: 'application/json', - 'X-Nomad-Token': token || '', - }, + 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).send(body); + return; + } // Deserialize and return const allocationsBody = await allocationsResp.json(); logger.debug(`/v1/allocations response: ${allocationsBody}`); - resp.json(allocationsBody); + res.json(allocationsBody); }); router.get('/v1/job/:job_id/versions', async (req, resp) => { @@ -82,8 +88,10 @@ export async function createRouter( // Get job ID const jobID = (req.params.job_id as string) ?? ''; + logger.info(`token: ${token}`); + // Issue the request - const versionsResp = await fetch( + const apiResp = await fetch( `${addr}/v1/job/${jobID}/versions?namespace=${encodeURIComponent( namespace, )}`, @@ -96,8 +104,16 @@ export async function createRouter( }, ); + // 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).send(body); + return; + } + // Deserialize and return - const versionsBody = await versionsResp.json(); + const versionsBody = await apiResp.json(); logger.debug(`/v1/job/:job_id/versions response: ${versionsBody}`); resp.json(versionsBody); }); diff --git a/plugins/nomad/README.md b/plugins/nomad/README.md index ef83b34fe1..593888f64c 100644 --- a/plugins/nomad/README.md +++ b/plugins/nomad/README.md @@ -12,7 +12,7 @@ This plugin has a corresponding backend plugin required to call the Nomad cluste ### Features -At the time of writing, this plugin provides two components: +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) @@ -25,7 +25,7 @@ You will need to have the backend Nomad plugin, `@backstage/plugin-nomad-backend 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 [`list-jobs` and `read-jobs` capabilities](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. +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 @@ -121,3 +121,47 @@ 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). From 60808ee54ada2a96affe0d9ca258a793d9c8733b Mon Sep 17 00:00:00 2001 From: josh Date: Mon, 5 Jun 2023 21:01:41 -0400 Subject: [PATCH 15/34] Update README Signed-off-by: josh --- plugins/nomad/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/nomad/README.md b/plugins/nomad/README.md index 593888f64c..aa93b57f07 100644 --- a/plugins/nomad/README.md +++ b/plugins/nomad/README.md @@ -40,8 +40,8 @@ Add configuration to your [`app-config.yaml`](https://github.com/backstage/backs ```yaml nomad: - addr: http://localhost:4646 - token: 70d707b6-3d45-472e-8639-6b15770c19b8 + addr: 'http://localhost:4646' + token: '70d707b6-3d45-472e-8639-6b15770c19b8' ``` The `token` can be excluded if [ACLs are not enabled](https://developer.hashicorp.com/nomad/api-docs#authentication). From e04ed79f33f7c181de39e29552aef29b22ff7473 Mon Sep 17 00:00:00 2001 From: josh Date: Wed, 7 Jun 2023 22:52:59 -0400 Subject: [PATCH 16/34] Add backend api doc Signed-off-by: josh --- plugins/nomad-backend/api-report.md | 22 +++++++++++++++++++++ plugins/nomad-backend/src/service/router.ts | 2 ++ plugins/nomad/README.md | 2 +- 3 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 plugins/nomad-backend/api-report.md 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/src/service/router.ts b/plugins/nomad-backend/src/service/router.ts index 7c462d811e..a1b820866c 100644 --- a/plugins/nomad-backend/src/service/router.ts +++ b/plugins/nomad-backend/src/service/router.ts @@ -20,11 +20,13 @@ 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 { diff --git a/plugins/nomad/README.md b/plugins/nomad/README.md index aa93b57f07..1a38cc299a 100644 --- a/plugins/nomad/README.md +++ b/plugins/nomad/README.md @@ -41,7 +41,7 @@ Add configuration to your [`app-config.yaml`](https://github.com/backstage/backs ```yaml nomad: addr: 'http://localhost:4646' - token: '70d707b6-3d45-472e-8639-6b15770c19b8' + token: '93e034ad-e504-42f9-129d-5d81be9f13d3' ``` The `token` can be excluded if [ACLs are not enabled](https://developer.hashicorp.com/nomad/api-docs#authentication). From 839cb29b3b0d55e37e22a6d750366dd432a379ed Mon Sep 17 00:00:00 2001 From: josh Date: Wed, 7 Jun 2023 22:54:58 -0400 Subject: [PATCH 17/34] Add links to Nomad refs Signed-off-by: josh --- plugins/nomad-backend/README.md | 2 +- plugins/nomad/README.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/nomad-backend/README.md b/plugins/nomad-backend/README.md index bb0b48fe4a..d06444809e 100644 --- a/plugins/nomad-backend/README.md +++ b/plugins/nomad-backend/README.md @@ -1,6 +1,6 @@ # @backstage/plugin-nomad-backend -A backend for Nomad, 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. +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 diff --git a/plugins/nomad/README.md b/plugins/nomad/README.md index 1a38cc299a..da14a0a3f3 100644 --- a/plugins/nomad/README.md +++ b/plugins/nomad/README.md @@ -1,12 +1,12 @@ # @backstage/plugin-nomad -This is a frontend plugin is for viewing Nomad [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 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 +### [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. From 621c58329a25250c8e44e414de006499531a0ff1 Mon Sep 17 00:00:00 2001 From: Carlos Esteban Lopez Date: Thu, 1 Jun 2023 16:37:40 -0500 Subject: [PATCH 18/34] fix(catalog-import): Fix stepper component order. Signed-off-by: Carlos Esteban Lopez Signed-off-by: josh --- .../components/DefaultImportPage/DefaultImportPage.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.tsx b/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.tsx index 30cce9603a..f0c9d3c33a 100644 --- a/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.tsx +++ b/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.tsx @@ -47,14 +47,14 @@ export const DefaultImportPage = () => { - - - - - + + + + + From 40c0b816826b972ae02f7364a2f3654cbd074ce3 Mon Sep 17 00:00:00 2001 From: josh Date: Wed, 7 Jun 2023 23:32:19 -0400 Subject: [PATCH 19/34] Add test value Signed-off-by: josh --- plugins/nomad-backend/src/service/router.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/plugins/nomad-backend/src/service/router.test.ts b/plugins/nomad-backend/src/service/router.test.ts index d94948cbbd..11c3834ed6 100644 --- a/plugins/nomad-backend/src/service/router.test.ts +++ b/plugins/nomad-backend/src/service/router.test.ts @@ -25,7 +25,12 @@ describe('createRouter', () => { beforeAll(async () => { const router = await createRouter({ - config: new ConfigReader({}), + config: new ConfigReader({ + nomad: { + addr: 'http://localhost.com', + token: 'asdf', + }, + }), logger: getVoidLogger(), }); app = express().use(router); From 05bf04ca6863747adaa3476ff06e8850917cfd9a Mon Sep 17 00:00:00 2001 From: josh Date: Wed, 7 Jun 2023 23:56:03 -0400 Subject: [PATCH 20/34] Add CHANGELOG Signed-off-by: josh --- .changeset/friendly-glasses-sleep.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/friendly-glasses-sleep.md diff --git a/.changeset/friendly-glasses-sleep.md b/.changeset/friendly-glasses-sleep.md new file mode 100644 index 0000000000..bbd940dddf --- /dev/null +++ b/.changeset/friendly-glasses-sleep.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-nomad-backend': patch +'@backstage/plugin-nomad': patch +--- + +Initialize Nomad plugin with frontend and backend. List jobs/allocations From 494b71692349622ff116884d14358722909b650f Mon Sep 17 00:00:00 2001 From: Joshua Timmons Date: Thu, 8 Jun 2023 17:40:03 -0400 Subject: [PATCH 21/34] Update .changeset/friendly-glasses-sleep.md Co-authored-by: Johan Haals Signed-off-by: Joshua Timmons Signed-off-by: josh --- .changeset/friendly-glasses-sleep.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/friendly-glasses-sleep.md b/.changeset/friendly-glasses-sleep.md index bbd940dddf..fa890eab06 100644 --- a/.changeset/friendly-glasses-sleep.md +++ b/.changeset/friendly-glasses-sleep.md @@ -1,6 +1,6 @@ --- -'@backstage/plugin-nomad-backend': patch -'@backstage/plugin-nomad': patch +'@backstage/plugin-nomad-backend': minor +'@backstage/plugin-nomad': minor --- Initialize Nomad plugin with frontend and backend. List jobs/allocations From 845ee8b316a46d60d62db792dadb96de9e609cb5 Mon Sep 17 00:00:00 2001 From: josh Date: Thu, 8 Jun 2023 17:40:55 -0400 Subject: [PATCH 22/34] Remove the promise return statement Signed-off-by: josh --- plugins/nomad/src/api.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/nomad/src/api.ts b/plugins/nomad/src/api.ts index 814bba5b6d..99f8b9906c 100644 --- a/plugins/nomad/src/api.ts +++ b/plugins/nomad/src/api.ts @@ -133,9 +133,9 @@ export class NomadHttpApi implements NomadApi { ); if (!resp.ok) throw await FetchError.forResponse(resp); - return Promise.resolve({ + return { allocations: await resp.json(), - }); + }; } // TODO: pagination @@ -153,8 +153,8 @@ export class NomadHttpApi implements NomadApi { const respJson = await resp.json(); - return Promise.resolve({ + return { versions: respJson.Versions, - }); + }; } } From 5cc406734767edf4900baa9efc18176bb80f02c5 Mon Sep 17 00:00:00 2001 From: josh Date: Thu, 8 Jun 2023 17:43:13 -0400 Subject: [PATCH 23/34] Add api report Signed-off-by: josh --- plugins/nomad/api-report.md | 40 +++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 plugins/nomad/api-report.md diff --git a/plugins/nomad/api-report.md b/plugins/nomad/api-report.md new file mode 100644 index 0000000000..8c41b8791c --- /dev/null +++ b/plugins/nomad/api-report.md @@ -0,0 +1,40 @@ +## 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 (undocumented) +export const EmbeddedRouter: () => JSX.Element; + +// @public +export const EntityNomadAllocationListTable: () => JSX.Element; + +// @public +export const EntityNomadJobVersionListCard: () => JSX.Element; + +// @public (undocumented) +export const isNomadAllocationsAvailable: (entity: Entity) => boolean; + +// @public (undocumented) +export const isNomadJobIDAvailable: (entity: Entity) => boolean; + +// Warning: (ae-missing-release-tag) "nomadPlugin" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const nomadPlugin: BackstagePlugin< + { + root: RouteRef; + entityContent: RouteRef; + }, + {}, + {} +>; + +// (No @packageDocumentation comment for this package) +``` From bb1bc181987fa4fa15525cf268d7f06761e7aa1a Mon Sep 17 00:00:00 2001 From: josh Date: Thu, 8 Jun 2023 18:24:42 -0400 Subject: [PATCH 24/34] Make router public Signed-off-by: josh --- plugins/nomad/src/plugin.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/nomad/src/plugin.ts b/plugins/nomad/src/plugin.ts index d8367d97df..c97d5218a1 100644 --- a/plugins/nomad/src/plugin.ts +++ b/plugins/nomad/src/plugin.ts @@ -30,6 +30,7 @@ export const entityContentRouteRef = createRouteRef({ id: 'nomad:entity-content', }); +// @public export const nomadPlugin = createPlugin({ id: 'nomad', apis: [ From 16c48006ff162274eede499718900aac75827929 Mon Sep 17 00:00:00 2001 From: josh Date: Thu, 8 Jun 2023 18:30:10 -0400 Subject: [PATCH 25/34] Fix api-report Signed-off-by: josh --- plugins/nomad/api-report.md | 2 -- plugins/nomad/src/plugin.ts | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/plugins/nomad/api-report.md b/plugins/nomad/api-report.md index 8c41b8791c..8420a6060e 100644 --- a/plugins/nomad/api-report.md +++ b/plugins/nomad/api-report.md @@ -24,8 +24,6 @@ export const isNomadAllocationsAvailable: (entity: Entity) => boolean; // @public (undocumented) export const isNomadJobIDAvailable: (entity: Entity) => boolean; -// Warning: (ae-missing-release-tag) "nomadPlugin" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const nomadPlugin: BackstagePlugin< { diff --git a/plugins/nomad/src/plugin.ts b/plugins/nomad/src/plugin.ts index c97d5218a1..30dbff77d1 100644 --- a/plugins/nomad/src/plugin.ts +++ b/plugins/nomad/src/plugin.ts @@ -30,7 +30,7 @@ export const entityContentRouteRef = createRouteRef({ id: 'nomad:entity-content', }); -// @public +/** @public */ export const nomadPlugin = createPlugin({ id: 'nomad', apis: [ From 96c5c6f3fe7d7356d01be296fbe451b357e2d11f Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 9 Jun 2023 22:36:51 -0400 Subject: [PATCH 26/34] Fix configSchema title Signed-off-by: josh --- plugins/nomad/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/nomad/package.json b/plugins/nomad/package.json index c4d5ad3d75..86c15f3aaa 100644 --- a/plugins/nomad/package.json +++ b/plugins/nomad/package.json @@ -54,7 +54,7 @@ ], "configSchema": { "$schema": "https://backstage.io/schema/config-v1", - "title": "@backstage/nomad", + "title": "@backstage/plugin-nomad", "type": "object", "properties": { "nomad": { From 7a995cf40251688a811796b06046780ec1e52fba Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 9 Jun 2023 22:55:21 -0400 Subject: [PATCH 27/34] Use luxon for DateTime Signed-off-by: josh --- .../EntityNomadAllocationListTable.tsx | 6 +++++- .../EntityNomadJobVersionListCard.tsx | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/plugins/nomad/src/components/EntityNomadAllocationListTable/EntityNomadAllocationListTable.tsx b/plugins/nomad/src/components/EntityNomadAllocationListTable/EntityNomadAllocationListTable.tsx index bd577f555b..6d7a79c002 100644 --- a/plugins/nomad/src/components/EntityNomadAllocationListTable/EntityNomadAllocationListTable.tsx +++ b/plugins/nomad/src/components/EntityNomadAllocationListTable/EntityNomadAllocationListTable.tsx @@ -14,6 +14,7 @@ * limitations under the License. */ +import { DateTime } from 'luxon'; import { Link, MissingAnnotationEmptyState, @@ -64,7 +65,10 @@ const columns: TableColumn[] = [ { title: 'Created', field: 'CreateTime', - render: row => new Date(row.CreateTime / 1000000).toLocaleString(), + render: row => + DateTime.fromMillis(row.CreateTime / 1000000).toLocaleString( + DateTime.DATETIME_MED_WITH_SECONDS, + ), }, { title: 'Status', diff --git a/plugins/nomad/src/components/EntityNomadJobVersionListCard/EntityNomadJobVersionListCard.tsx b/plugins/nomad/src/components/EntityNomadJobVersionListCard/EntityNomadJobVersionListCard.tsx index c4dad1f455..c40a7fbeed 100644 --- a/plugins/nomad/src/components/EntityNomadJobVersionListCard/EntityNomadJobVersionListCard.tsx +++ b/plugins/nomad/src/components/EntityNomadJobVersionListCard/EntityNomadJobVersionListCard.tsx @@ -14,6 +14,7 @@ * limitations under the License. */ +import { DateTime } from 'luxon'; import { InfoCard, MissingAnnotationEmptyState, @@ -49,7 +50,10 @@ const columns: TableColumn[] = [ { title: 'Submitted', field: 'SubmitTime', - render: row => new Date(row.SubmitTime / 1000000).toLocaleString(), + render: row => + DateTime.fromMillis(row.SubmitTime / 1000000).toLocaleString( + DateTime.DATETIME_MED_WITH_SECONDS, + ), }, ]; From 605cda66078170128d0e4877a50240d51a8e469b Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 9 Jun 2023 22:57:56 -0400 Subject: [PATCH 28/34] Use json, not send Signed-off-by: josh --- plugins/nomad-backend/src/service/router.ts | 8 ++++++-- plugins/nomad/package.json | 1 + yarn.lock | 1 + 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/plugins/nomad-backend/src/service/router.ts b/plugins/nomad-backend/src/service/router.ts index a1b820866c..086137e2d7 100644 --- a/plugins/nomad-backend/src/service/router.ts +++ b/plugins/nomad-backend/src/service/router.ts @@ -70,7 +70,9 @@ export async function createRouter( if (allocationsResp.status !== 200) { const body = await allocationsResp.text(); logger.error(`failed to call /v1/allocations endpoint: ${body}`); - res.status(allocationsResp.status).send(body); + res.status(allocationsResp.status).json({ + message: body, + }); return; } @@ -110,7 +112,9 @@ export async function createRouter( 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).send(body); + resp.status(apiResp.status).send({ + message: body, + }); return; } diff --git a/plugins/nomad/package.json b/plugins/nomad/package.json index 86c15f3aaa..7501aabf31 100644 --- a/plugins/nomad/package.json +++ b/plugins/nomad/package.json @@ -31,6 +31,7 @@ "@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": { diff --git a/yarn.lock b/yarn.lock index cadfdfc9b9..3c9711367f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7969,6 +7969,7 @@ __metadata: "@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: From f81a0dcec872bd4262a9c543450a48587db1e027 Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 9 Jun 2023 22:59:13 -0400 Subject: [PATCH 29/34] Use create for NomadHttpApi static constructor Signed-off-by: josh --- plugins/nomad/src/api.ts | 2 +- plugins/nomad/src/plugin.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/nomad/src/api.ts b/plugins/nomad/src/api.ts index 99f8b9906c..44aa5d48d2 100644 --- a/plugins/nomad/src/api.ts +++ b/plugins/nomad/src/api.ts @@ -114,7 +114,7 @@ export class FetchError extends Error { /** @public */ export class NomadHttpApi implements NomadApi { - static new(discoveryApi: DiscoveryApi, fetchApi: FetchApi) { + static create(discoveryApi: DiscoveryApi, fetchApi: FetchApi) { return new NomadHttpApi(discoveryApi, fetchApi); } diff --git a/plugins/nomad/src/plugin.ts b/plugins/nomad/src/plugin.ts index 30dbff77d1..df89785941 100644 --- a/plugins/nomad/src/plugin.ts +++ b/plugins/nomad/src/plugin.ts @@ -38,7 +38,7 @@ export const nomadPlugin = createPlugin({ api: nomadApiRef, deps: { discoveryApi: discoveryApiRef, fetchApi: fetchApiRef }, factory: ({ discoveryApi, fetchApi }) => - NomadHttpApi.new(discoveryApi, fetchApi), + NomadHttpApi.create(discoveryApi, fetchApi), }), ], routes: { From 031b8bed5aa50b5fdac0fddb5902cfb067b076fd Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 9 Jun 2023 23:05:16 -0400 Subject: [PATCH 30/34] Remove EmbeddedRouter from public API Signed-off-by: josh --- plugins/nomad/api-report.md | 3 --- plugins/nomad/src/Router.tsx | 1 - plugins/nomad/src/index.ts | 6 +----- 3 files changed, 1 insertion(+), 9 deletions(-) diff --git a/plugins/nomad/api-report.md b/plugins/nomad/api-report.md index 8420a6060e..46d3f2b81b 100644 --- a/plugins/nomad/api-report.md +++ b/plugins/nomad/api-report.md @@ -9,9 +9,6 @@ import { BackstagePlugin } from '@backstage/core-plugin-api'; import { Entity } from '@backstage/catalog-model'; import { RouteRef } from '@backstage/core-plugin-api'; -// @public (undocumented) -export const EmbeddedRouter: () => JSX.Element; - // @public export const EntityNomadAllocationListTable: () => JSX.Element; diff --git a/plugins/nomad/src/Router.tsx b/plugins/nomad/src/Router.tsx index dadeda86db..d7e6db1b44 100644 --- a/plugins/nomad/src/Router.tsx +++ b/plugins/nomad/src/Router.tsx @@ -39,7 +39,6 @@ export const isNomadAllocationsAvailable = (entity: Entity) => Boolean(entity.metadata.annotations?.[NOMAD_JOB_ID_ANNOTATION]) || Boolean(entity.metadata.annotations?.[NOMAD_GROUP_ANNOTATION]); -/** @public */ export const EmbeddedRouter = () => { const { entity } = useEntity(); diff --git a/plugins/nomad/src/index.ts b/plugins/nomad/src/index.ts index 2b95af217a..3785a5c7b5 100644 --- a/plugins/nomad/src/index.ts +++ b/plugins/nomad/src/index.ts @@ -18,8 +18,4 @@ export { EntityNomadJobVersionListCard, EntityNomadAllocationListTable, } from './plugin'; -export { - isNomadAllocationsAvailable, - isNomadJobIDAvailable, - EmbeddedRouter, -} from './Router'; +export { isNomadAllocationsAvailable, isNomadJobIDAvailable } from './Router'; From ec80baa602a3ee84ed869200ea181ac2d416670d Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 9 Jun 2023 23:07:41 -0400 Subject: [PATCH 31/34] Just return null if no entity Signed-off-by: josh --- .../EntityNomadJobVersionListCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/nomad/src/components/EntityNomadJobVersionListCard/EntityNomadJobVersionListCard.tsx b/plugins/nomad/src/components/EntityNomadJobVersionListCard/EntityNomadJobVersionListCard.tsx index c40a7fbeed..0c1863b95e 100644 --- a/plugins/nomad/src/components/EntityNomadJobVersionListCard/EntityNomadJobVersionListCard.tsx +++ b/plugins/nomad/src/components/EntityNomadJobVersionListCard/EntityNomadJobVersionListCard.tsx @@ -114,7 +114,7 @@ export const EntityNomadJobVersionListCard = () => { return ; } - if (!entity) return <>; + if (!entity) return null; // Check that job ID is set if (!isNomadJobIDAvailable(entity)) { From 533b3f9a90dba938c7b3196e457feb74a68bb6b0 Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 9 Jun 2023 23:17:44 -0400 Subject: [PATCH 32/34] Update api-report Signed-off-by: josh --- plugins/nomad/api-report.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/nomad/api-report.md b/plugins/nomad/api-report.md index 46d3f2b81b..f000854374 100644 --- a/plugins/nomad/api-report.md +++ b/plugins/nomad/api-report.md @@ -13,7 +13,7 @@ import { RouteRef } from '@backstage/core-plugin-api'; export const EntityNomadAllocationListTable: () => JSX.Element; // @public -export const EntityNomadJobVersionListCard: () => JSX.Element; +export const EntityNomadJobVersionListCard: () => JSX.Element | null; // @public (undocumented) export const isNomadAllocationsAvailable: (entity: Entity) => boolean; From 8db173810283ccdf147f653cd224c32a39aa83dd Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 9 Jun 2023 23:26:44 -0400 Subject: [PATCH 33/34] Use node-fetch in backend plugin Signed-off-by: josh --- plugins/nomad-backend/src/service/router.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/nomad-backend/src/service/router.ts b/plugins/nomad-backend/src/service/router.ts index 086137e2d7..f36c9e657e 100644 --- a/plugins/nomad-backend/src/service/router.ts +++ b/plugins/nomad-backend/src/service/router.ts @@ -13,6 +13,8 @@ * 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'; From c59922ed41778319bd70f591b9b12f54e38ba589 Mon Sep 17 00:00:00 2001 From: josh Date: Wed, 14 Jun 2023 10:06:59 -0400 Subject: [PATCH 34/34] Replace last send usage Signed-off-by: josh --- plugins/nomad-backend/src/service/router.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/plugins/nomad-backend/src/service/router.ts b/plugins/nomad-backend/src/service/router.ts index f36c9e657e..f9355b27d0 100644 --- a/plugins/nomad-backend/src/service/router.ts +++ b/plugins/nomad-backend/src/service/router.ts @@ -72,9 +72,7 @@ export async function createRouter( 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, - }); + res.status(allocationsResp.status).json({ message: body }); return; } @@ -114,9 +112,7 @@ export async function createRouter( 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).send({ - message: body, - }); + resp.status(apiResp.status).json({ message: body }); return; }