diff --git a/.changeset/good-islands-cheer.md b/.changeset/good-islands-cheer.md new file mode 100644 index 0000000000..2e7109b312 --- /dev/null +++ b/.changeset/good-islands-cheer.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-cost-insights': minor +--- + +Enable custom alert types in Cost Insights diff --git a/.codecov.yml b/.github/codecov.yml similarity index 100% rename from .codecov.yml rename to .github/codecov.yml diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index c9774ae237..59a0eadb49 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -141,6 +141,7 @@ Raghunandan rankdir readme Readme +Recharts Redash replicasets repo diff --git a/CHANGELOG.md b/CHANGELOG.md index 1860a4de6f..487b94a39d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ If you encounter issues while upgrading to a newer version, don't hesitate to re > Collect changes for the next release below +### Backend (example-backend, or backends created with @backstage/create-app) + +- A plugin database manager has been created, and plugins can now accept that interface as an argument during initialisation. Notably, the `auth` plugin has a [`createRouter` signature change](./plugins/auth-backend/src/service/router.ts). See [packages/backend/src/index.ts](./packages/backend/src/index.ts) on how to set it up. [#2697](https://github.com/spotify/backstage/pull/2697) + ## v0.1.1-alpha.24 ### Backend (example-backend, or backends created with @backstage/create-app) diff --git a/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts b/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts index bd612cdb59..aa795e9eee 100644 --- a/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts +++ b/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts @@ -27,9 +27,14 @@ import { exclusiveEndDateOf, Group, inclusiveStartDateOf, + Maybe, ProductCost, Project, + ProjectGrowthAlert, + ProjectGrowthData, Trendline, + UnlabeledDataflowAlert, + UnlabeledDataflowData, } from '@backstage/plugin-cost-insights'; function durationOf(intervals: string): Duration { @@ -159,9 +164,38 @@ export class ExampleCostInsightsClient implements CostInsightsApi { product: string, group: string, duration: Duration, + project: Maybe, ): Promise { + const projectProductInsights = await this.request( + { product, group, duration, project }, + { + aggregation: [80_000, 110_000], + change: { + ratio: 0.375, + amount: 30_000, + }, + entities: [ + { + id: null, // entities with null ids will be appear as "Unlabeled" in product panels + aggregation: [45_000, 50_000], + }, + { + id: 'entity-a', + aggregation: [15_000, 20_000], + }, + { + id: 'entity-b', + aggregation: [20_000, 30_000], + }, + { + id: 'entity-e', + aggregation: [0, 10_000], + }, + ], + }, + ); const productInsights: ProductCost = await this.request( - { product, group, duration }, + { product, group, duration, project }, { aggregation: [200_000, 250_000], change: { @@ -209,36 +243,48 @@ export class ExampleCostInsightsClient implements CostInsightsApi { }, ); - return productInsights; + return project ? projectProductInsights : productInsights; } async getAlerts(group: string): Promise { - const alerts: Alert[] = await this.request({ group }, [ - { - id: 'projectGrowth', - project: 'example-project', - periodStart: 'Q1 2020', - periodEnd: 'Q2 2020', - aggregation: [60_000, 120_000], - change: { - ratio: 1, - amount: 60000, - }, - products: [ - { - id: 'Compute Engine', - aggregation: [58_000, 118_000], - }, - { - id: 'Cloud Dataflow', - aggregation: [1200, 1500], - }, - { - id: 'Cloud Storage', - aggregation: [800, 500], - }, - ], + const projectGrowthData: ProjectGrowthData = { + project: 'example-project', + periodStart: 'Q2 2020', + periodEnd: 'Q3 2020', + aggregation: [60_000, 120_000], + change: { + ratio: 1, + amount: 60000, }, + products: [ + { id: 'Compute Engine', aggregation: [58_000, 118_000] }, + { id: 'Cloud Dataflow', aggregation: [1200, 1500] }, + { id: 'Cloud Storage', aggregation: [800, 500] }, + ], + }; + + const unlabeledDataflowData: UnlabeledDataflowData = { + periodStart: '2020-09-01', + periodEnd: '2020-09-30', + labeledCost: 6_200, + unlabeledCost: 7_000, + projects: [ + { + id: 'example-project-1', + unlabeledCost: 5_000, + labeledCost: 3_000, + }, + { + id: 'example-project-2', + unlabeledCost: 2_000, + labeledCost: 3_200, + }, + ], + }; + + const alerts: Alert[] = await this.request({ group }, [ + new ProjectGrowthAlert(projectGrowthData), + new UnlabeledDataflowAlert(unlabeledDataflowData), ]); return alerts; diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index 1c888ab8fe..b012b5205b 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -39,7 +39,7 @@ "express": "^4.17.1", "express-prom-bundle": "^6.1.0", "express-promise-router": "^3.0.3", - "git-url-parse": "^11.2.0", + "git-url-parse": "^11.3.0", "helmet": "^4.0.0", "knex": "^0.21.1", "lodash": "^4.17.15", diff --git a/packages/backend-common/src/database/SingleConnection.test.ts b/packages/backend-common/src/database/SingleConnection.test.ts new file mode 100644 index 0000000000..6278e4ecad --- /dev/null +++ b/packages/backend-common/src/database/SingleConnection.test.ts @@ -0,0 +1,97 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { ConfigReader } from '@backstage/config'; +import { createDatabaseClient } from './connection'; +import { SingleConnectionDatabaseManager } from './SingleConnection'; + +jest.mock('./connection'); + +describe('SingleConnectionDatabaseManager', () => { + const createConfig = (data: any) => + ConfigReader.fromConfigs([ + { + context: '', + data, + }, + ]); + + const defaultConfigOptions = { + backend: { + database: { + client: 'pg', + connection: { + host: 'localhost', + user: 'foo', + password: 'bar', + database: 'foodb', + }, + }, + }, + }; + const defaultConfig = () => createConfig(defaultConfigOptions); + + // This is similar to the ts-jest `mocked` helper. + const mocked = (f: Function) => f as jest.Mock; + + afterEach(() => jest.resetAllMocks()); + + describe('SingleConnectionDatabaseManager.fromConfig', () => { + it('accesses the backend.database key', () => { + const getConfig = jest.fn(); + const config = defaultConfig(); + config.getConfig = getConfig; + + SingleConnectionDatabaseManager.fromConfig(config); + + expect(getConfig.mock.calls[0][0]).toEqual('backend.database'); + }); + }); + + describe('SingleConnectionDatabaseManager.forPlugin', () => { + const manager = SingleConnectionDatabaseManager.fromConfig(defaultConfig()); + + it('connects to a database scoped to the plugin', async () => { + const pluginId = 'test1'; + await manager.forPlugin(pluginId).getClient(); + + expect(mocked(createDatabaseClient)).toHaveBeenCalledTimes(1); + + const mockCalls = mocked(createDatabaseClient).mock.calls.splice(-1); + const callArgs = mockCalls[0]; + expect(callArgs[0].get()).toEqual(defaultConfigOptions.backend.database); + expect(callArgs[1].connection.database).toEqual( + `backstage_plugin_${pluginId}`, + ); + }); + + it('provides different plugins different databases', async () => { + const plugin1Id = 'test1'; + const plugin2Id = 'test2'; + await manager.forPlugin(plugin1Id).getClient(); + await manager.forPlugin(plugin2Id).getClient(); + + expect(mocked(createDatabaseClient)).toHaveBeenCalledTimes(2); + + const mockCalls = mocked(createDatabaseClient).mock.calls; + const plugin1CallArgs = mockCalls[0]; + const plugin2CallArgs = mockCalls[1]; + expect(plugin1CallArgs[1].connection.database).not.toEqual( + plugin2CallArgs[1].connection.database, + ); + }); + }); +}); diff --git a/packages/backend-common/src/database/SingleConnection.ts b/packages/backend-common/src/database/SingleConnection.ts new file mode 100644 index 0000000000..777ced54a0 --- /dev/null +++ b/packages/backend-common/src/database/SingleConnection.ts @@ -0,0 +1,82 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 Knex from 'knex'; +import { Config } from '@backstage/config'; +import { createDatabaseClient, ensureDatabaseExists } from './connection'; +import { PluginDatabaseManager } from './types'; + +/** + * Implements a Database Manager which will automatically create new databases + * for plugins when requested. All requested databases are created with the + * credentials provided; if the database already exists no attempt to create + * the database will be made. + */ +export class SingleConnectionDatabaseManager { + /** + * Creates a new SingleConnectionDatabaseManager instance by reading from the `backend` + * config section, specifically the `.database` key for discovering the management + * database configuration. + * + * @param config The loaded application configuration. + */ + static fromConfig(config: Config): SingleConnectionDatabaseManager { + return new SingleConnectionDatabaseManager( + config.getConfig('backend.database'), + ); + } + + private constructor(private readonly config: Config) {} + + /** + * Generates a PluginDatabaseManager for consumption by plugins. + * + * @param pluginId The plugin that the database manager should be created for. Plugin names should be unique. + */ + forPlugin(pluginId: string): PluginDatabaseManager { + const _this = this; + + return { + getClient(): Promise { + return _this.getDatabase(pluginId); + }, + }; + } + + private async getDatabase(pluginId: string): Promise { + const config = this.config; + const overrides = SingleConnectionDatabaseManager.getDatabaseOverrides( + pluginId, + ); + const overrideConfig = overrides.connection as Knex.ConnectionConfig; + await this.ensureDatabase(overrideConfig.database); + + return createDatabaseClient(config, overrides); + } + + private static getDatabaseOverrides(pluginId: string): Knex.Config { + return { + connection: { + database: `backstage_plugin_${pluginId}`, + }, + }; + } + + private async ensureDatabase(database: string) { + const config = this.config; + await ensureDatabaseExists(config, database); + } +} diff --git a/packages/backend-common/src/database/config.test.ts b/packages/backend-common/src/database/config.test.ts index ab29bc19ba..c9a4107686 100644 --- a/packages/backend-common/src/database/config.test.ts +++ b/packages/backend-common/src/database/config.test.ts @@ -18,6 +18,18 @@ import { mergeDatabaseConfig } from './config'; describe('config', () => { describe(mergeDatabaseConfig, () => { + it('does not mutate the input object', () => { + const input = { + original: 'key', + }; + const override = { + added: 'value', + }; + + mergeDatabaseConfig(input, override); + expect(input).not.toHaveProperty('added'); + }); + it('does not require overrides', () => { expect( mergeDatabaseConfig({ diff --git a/packages/backend-common/src/database/config.ts b/packages/backend-common/src/database/config.ts index 80abc06aa2..af32556c28 100644 --- a/packages/backend-common/src/database/config.ts +++ b/packages/backend-common/src/database/config.ts @@ -19,9 +19,9 @@ import { merge } from 'lodash'; /** * Merges database objects together * - * @param config The base config + * @param config The base config. The input is not modified * @param overrides Any additional overrides */ export function mergeDatabaseConfig(config: any, ...overrides: any[]) { - return merge(config, ...overrides); + return merge({}, config, ...overrides); } diff --git a/packages/backend-common/src/database/index.ts b/packages/backend-common/src/database/index.ts index 38d3d6224b..c81153aa62 100644 --- a/packages/backend-common/src/database/index.ts +++ b/packages/backend-common/src/database/index.ts @@ -15,3 +15,5 @@ */ export * from './connection'; +export * from './types'; +export * from './SingleConnection'; diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewFooter.tsx b/packages/backend-common/src/database/types.ts similarity index 56% rename from plugins/cost-insights/src/components/CostOverviewCard/CostOverviewFooter.tsx rename to packages/backend-common/src/database/types.ts index 03920887b8..7eaa05f173 100644 --- a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewFooter.tsx +++ b/packages/backend-common/src/database/types.ts @@ -13,24 +13,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; -import { Box } from '@material-ui/core'; -type CostOverviewFooterProps = { - children?: React.ReactNode; -}; +import knex from 'knex'; -const CostOverviewFooter = ({ children }: CostOverviewFooterProps) => ( - - {React.Children.map(children, child => ( - {child} - ))} - -); - -export default CostOverviewFooter; +/** + * The PluginDatabaseManager manages access to databases that Plugins get. + */ +export interface PluginDatabaseManager { + /** + * getClient provides backend plugins database connections for itself. + * + * The purpose of this method is to allow plugins to get isolated data + * stores so that plugins are discouraged from database integration. + */ + getClient(): Promise; +} diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 959af71427..18fd44f089 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -24,17 +24,16 @@ import Router from 'express-promise-router'; import { - ensureDatabaseExists, - createDatabaseClient, createServiceBuilder, loadBackendConfig, getRootLogger, useHotMemoize, notFoundHandler, + SingleConnectionDatabaseManager, SingleHostDiscovery, UrlReaders, } from '@backstage/backend-common'; -import { ConfigReader, AppConfig } from '@backstage/config'; +import { ConfigReader } from '@backstage/config'; import healthcheck from './plugins/healthcheck'; import auth from './plugins/auth'; import catalog from './plugins/catalog'; @@ -48,24 +47,18 @@ import graphql from './plugins/graphql'; import app from './plugins/app'; import { PluginEnvironment } from './types'; -function makeCreateEnv(loadedConfigs: AppConfig[]) { - const config = ConfigReader.fromConfigs(loadedConfigs); +function makeCreateEnv(config: ConfigReader) { const root = getRootLogger(); const reader = UrlReaders.default({ logger: root, config }); const discovery = SingleHostDiscovery.fromConfig(config); root.info(`Created UrlReader ${reader}`); + const databaseManager = SingleConnectionDatabaseManager.fromConfig(config); + return (plugin: string): PluginEnvironment => { const logger = root.child({ type: 'plugin', plugin }); - const database = createDatabaseClient( - config.getConfig('backend.database'), - { - connection: { - database: `backstage_plugin_${plugin}`, - }, - }, - ); + const database = databaseManager.forPlugin(plugin); return { logger, database, config, reader, discovery }; }; } @@ -73,12 +66,7 @@ function makeCreateEnv(loadedConfigs: AppConfig[]) { async function main() { const configs = await loadBackendConfig(); const configReader = ConfigReader.fromConfigs(configs); - const createEnv = makeCreateEnv(configs); - await ensureDatabaseExists( - configReader.getConfig('backend.database'), - 'backstage_plugin_catalog', - 'backstage_plugin_auth', - ); + const createEnv = makeCreateEnv(configReader); const healthcheckEnv = useHotMemoize(module, () => createEnv('healthcheck')); const catalogEnv = useHotMemoize(module, () => createEnv('catalog')); diff --git a/packages/backend/src/plugins/catalog.ts b/packages/backend/src/plugins/catalog.ts index c1d390f8cc..b8e99e6398 100644 --- a/packages/backend/src/plugins/catalog.ts +++ b/packages/backend/src/plugins/catalog.ts @@ -34,7 +34,9 @@ export default async function createPlugin({ }: PluginEnvironment) { const locationReader = new LocationReaders({ logger, reader, config }); - const db = await DatabaseManager.createDatabase(database, { logger }); + const db = await DatabaseManager.createDatabase(await database.getClient(), { + logger, + }); const entitiesCatalog = new DatabaseEntitiesCatalog(db); const locationsCatalog = new DatabaseLocationsCatalog(db); const higherOrderOperation = new HigherOrderOperations( diff --git a/packages/backend/src/plugins/techdocs.ts b/packages/backend/src/plugins/techdocs.ts index e04d2a2732..6e36c26f39 100644 --- a/packages/backend/src/plugins/techdocs.ts +++ b/packages/backend/src/plugins/techdocs.ts @@ -23,6 +23,7 @@ import { TechdocsGenerator, GithubPreparer, GitlabPreparer, + AzurePreparer, } from '@backstage/plugin-techdocs-backend'; import { PluginEnvironment } from '../types'; import Docker from 'dockerode'; @@ -39,10 +40,12 @@ export default async function createPlugin({ const preparers = new Preparers(); const githubPreparer = new GithubPreparer(logger); const gitlabPreparer = new GitlabPreparer(logger); + const azurePreparer = new AzurePreparer(logger); const directoryPreparer = new DirectoryPreparer(logger); preparers.register('dir', directoryPreparer); preparers.register('github', githubPreparer); preparers.register('gitlab', gitlabPreparer); + preparers.register('azure/api', azurePreparer); const publisher = new LocalPublish(logger); diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 9257fcc9cf..f63b9dd780 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -14,14 +14,17 @@ * limitations under the License. */ -import Knex from 'knex'; import { Logger } from 'winston'; import { Config } from '@backstage/config'; -import { PluginEndpointDiscovery, UrlReader } from '@backstage/backend-common'; +import { + PluginDatabaseManager, + PluginEndpointDiscovery, + UrlReader, +} from '@backstage/backend-common'; export type PluginEnvironment = { logger: Logger; - database: Knex; + database: PluginDatabaseManager; config: Config; reader: UrlReader; discovery: PluginEndpointDiscovery; diff --git a/packages/cli/config/jest.js b/packages/cli/config/jest.js index f69e18190d..b9f709241c 100644 --- a/packages/cli/config/jest.js +++ b/packages/cli/config/jest.js @@ -96,7 +96,7 @@ async function getConfig() { // Default behaviour is to not apply transforms for node_modules, but we still want // to apply the esm-transformer to .esm.js files, since that's what we use in backstage packages. transformIgnorePatterns: [ - `/node_modules/${transformModulePattern}(?:(?!\\.esm).)*\\.(?:js|json)$`, + `/node_modules/${transformModulePattern}.*\\.(?:(? ( +export const MissingAnnotation = () => (
- +
); @@ -80,9 +76,3 @@ export const WithAction = () => ( /> ); - -export const MissingAnnotation = () => ( -
- -
-); diff --git a/packages/core/src/components/EmptyState/EmptyState.tsx b/packages/core/src/components/EmptyState/EmptyState.tsx index 4469c3ce86..1af5ff6dd5 100644 --- a/packages/core/src/components/EmptyState/EmptyState.tsx +++ b/packages/core/src/components/EmptyState/EmptyState.tsx @@ -17,7 +17,6 @@ import React from 'react'; import { makeStyles, Typography, Grid } from '@material-ui/core'; import { EmptyStateImage } from './EmptyStateImage'; -import Background from './assets/Background.svg'; const useStyles = makeStyles(theme => ({ root: { @@ -30,10 +29,6 @@ const useStyles = makeStyles(theme => ({ imageContainer: { position: 'relative', }, - backgroundImage: { - position: 'absolute', - width: '100%', - }, })); type Props = { @@ -66,11 +61,6 @@ export const EmptyState = ({ title, description, missing, action }: Props) => { - background diff --git a/packages/core/src/components/EmptyState/EmptyStateImage.tsx b/packages/core/src/components/EmptyState/EmptyStateImage.tsx index 49598ae6db..d52a0f7dcf 100644 --- a/packages/core/src/components/EmptyState/EmptyStateImage.tsx +++ b/packages/core/src/components/EmptyState/EmptyStateImage.tsx @@ -27,7 +27,7 @@ type Props = { const useStyles = makeStyles({ generalImg: { - width: '75%', + width: '95%', zIndex: 2, position: 'absolute', left: '50%', diff --git a/packages/core/src/components/EmptyState/assets/Background.svg b/packages/core/src/components/EmptyState/assets/Background.svg deleted file mode 100644 index ce9aae6739..0000000000 --- a/packages/core/src/components/EmptyState/assets/Background.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/packages/core/src/components/EmptyState/assets/createComponent.svg b/packages/core/src/components/EmptyState/assets/createComponent.svg index bb8b50e1ef..2634bb7f23 100644 --- a/packages/core/src/components/EmptyState/assets/createComponent.svg +++ b/packages/core/src/components/EmptyState/assets/createComponent.svg @@ -1 +1,36 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/core/src/components/EmptyState/assets/missingAnnotation.svg b/packages/core/src/components/EmptyState/assets/missingAnnotation.svg index 39534656fa..4f69605e6a 100644 --- a/packages/core/src/components/EmptyState/assets/missingAnnotation.svg +++ b/packages/core/src/components/EmptyState/assets/missingAnnotation.svg @@ -1 +1,42 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/core/src/components/EmptyState/assets/noBuild.svg b/packages/core/src/components/EmptyState/assets/noBuild.svg index ef45e1c736..d84f36af30 100644 --- a/packages/core/src/components/EmptyState/assets/noBuild.svg +++ b/packages/core/src/components/EmptyState/assets/noBuild.svg @@ -1 +1,44 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/core/src/components/EmptyState/assets/noInformation.svg b/packages/core/src/components/EmptyState/assets/noInformation.svg index 9a1c230e97..c199cf8b13 100644 --- a/packages/core/src/components/EmptyState/assets/noInformation.svg +++ b/packages/core/src/components/EmptyState/assets/noInformation.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/packages/create-app/src/createApp.ts b/packages/create-app/src/createApp.ts index 681bdb44f3..4f25dc9b9e 100644 --- a/packages/create-app/src/createApp.ts +++ b/packages/create-app/src/createApp.ts @@ -74,7 +74,6 @@ async function buildApp(appDir: string) { await runCmd('yarn install'); await runCmd('yarn tsc'); - await runCmd('yarn build'); } async function moveApp(tempDir: string, destination: string, id: string) { diff --git a/packages/create-app/templates/default-app/packages/backend/src/index.ts b/packages/create-app/templates/default-app/packages/backend/src/index.ts index 684db8aab8..d58ebe4e42 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/index.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/index.ts @@ -8,17 +8,16 @@ import Router from 'express-promise-router'; import { - ensureDatabaseExists, - createDatabaseClient, createServiceBuilder, loadBackendConfig, getRootLogger, useHotMemoize, notFoundHandler, + SingleConnectionDatabaseManager, SingleHostDiscovery, UrlReaders, } from '@backstage/backend-common'; -import { ConfigReader, AppConfig } from '@backstage/config'; +import { ConfigReader } from '@backstage/config'; import auth from './plugins/auth'; import catalog from './plugins/catalog'; import scaffolder from './plugins/scaffolder'; @@ -26,24 +25,18 @@ import proxy from './plugins/proxy'; import techdocs from './plugins/techdocs'; import { PluginEnvironment } from './types'; -function makeCreateEnv(loadedConfigs: AppConfig[]) { - const config = ConfigReader.fromConfigs(loadedConfigs); +function makeCreateEnv(config: ConfigReader) { const root = getRootLogger(); const reader = UrlReaders.default({ logger: root, config }); const discovery = SingleHostDiscovery.fromConfig(config); root.info(`Created UrlReader ${reader}`); + const databaseManager = SingleConnectionDatabaseManager.fromConfig(config); + return (plugin: string): PluginEnvironment => { const logger = root.child({ type: 'plugin', plugin }); - const database = createDatabaseClient( - config.getConfig('backend.database'), - { - connection: { - database: `backstage_plugin_${plugin}`, - }, - }, - ); + const database = databaseManager.forPlugin(plugin); return { logger, database, config, reader, discovery }; }; } @@ -51,12 +44,7 @@ function makeCreateEnv(loadedConfigs: AppConfig[]) { async function main() { const configs = await loadBackendConfig(); const configReader = ConfigReader.fromConfigs(configs); - const createEnv = makeCreateEnv(configs); - await ensureDatabaseExists( - configReader.getConfig('backend.database'), - 'backstage_plugin_catalog', - 'backstage_plugin_auth', - ); + const createEnv = makeCreateEnv(configReader); const catalogEnv = useHotMemoize(module, () => createEnv('catalog')); const scaffolderEnv = useHotMemoize(module, () => createEnv('scaffolder')); diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/catalog.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/catalog.ts index 345ac4c23d..aee38a5fa0 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/catalog.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/catalog.ts @@ -18,7 +18,9 @@ export default async function createPlugin({ }: PluginEnvironment) { const locationReader = new LocationReaders({ logger, reader, config }); - const db = await DatabaseManager.createDatabase(database, { logger }); + const db = await DatabaseManager.createDatabase(await database.getClient(), + { logger }, + ); const entitiesCatalog = new DatabaseEntitiesCatalog(db); const locationsCatalog = new DatabaseLocationsCatalog(db); const higherOrderOperation = new HigherOrderOperations( diff --git a/packages/create-app/templates/default-app/packages/backend/src/types.ts b/packages/create-app/templates/default-app/packages/backend/src/types.ts index aa003f63a5..757a0e5acf 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/types.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/types.ts @@ -1,11 +1,14 @@ -import Knex from 'knex'; import { Logger } from 'winston'; import { Config } from '@backstage/config'; -import { PluginEndpointDiscovery, UrlReader } from '@backstage/backend-common'; +import { + PluginDatabaseManager, + PluginEndpointDiscovery, + UrlReader, +} from '@backstage/backend-common'; export type PluginEnvironment = { logger: Logger; - database: Knex; + database: PluginDatabaseManager; config: Config; reader: UrlReader discovery: PluginEndpointDiscovery; diff --git a/packages/e2e-test/src/e2e-test.ts b/packages/e2e-test/src/e2e-test.ts index 6fbdbf3c88..fe3a9f6bb4 100644 --- a/packages/e2e-test/src/e2e-test.ts +++ b/packages/e2e-test/src/e2e-test.ts @@ -328,8 +328,8 @@ async function testAppServe(pluginName: string, appDir: string) { } } -/** Creates PG databases (drops if exists before) */ -async function createDB(database: string) { +/** Drops PG databases */ +async function dropDB(database: string) { const config = { host: process.env.POSTGRES_HOST, port: process.env.POSTGRES_PORT, @@ -342,7 +342,6 @@ async function createDB(database: string) { } catch (_) { /* do nothing*/ } - return pgtools.createdb(config, database); } /** @@ -350,7 +349,7 @@ async function createDB(database: string) { */ async function testBackendStart(appDir: string, isPostgres: boolean) { if (isPostgres) { - print('Creating DBs'); + print('Dropping old DBs'); await Promise.all( [ 'catalog', @@ -359,7 +358,7 @@ async function testBackendStart(appDir: string, isPostgres: boolean) { 'identity', 'proxy', 'techdocs', - ].map(name => createDB(`backstage_plugin_${name}`)), + ].map(name => dropDB(`backstage_plugin_${name}`)), ); print('Created DBs'); } diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index bcd1fc1794..6bb100de6b 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -17,7 +17,6 @@ import express from 'express'; import Router from 'express-promise-router'; import cookieParser from 'cookie-parser'; -import Knex from 'knex'; import { Logger } from 'winston'; import { createAuthProvider } from '../providers'; import { Config } from '@backstage/config'; @@ -25,11 +24,12 @@ import { DatabaseKeyStore, TokenFactory, createOidcRouter } from '../identity'; import { NotFoundError, PluginEndpointDiscovery, + PluginDatabaseManager, } from '@backstage/backend-common'; export interface RouterOptions { logger: Logger; - database: Knex; + database: PluginDatabaseManager; config: Config; discovery: PluginEndpointDiscovery; } @@ -47,7 +47,9 @@ export async function createRouter({ const keyDurationSeconds = 3600; - const keyStore = await DatabaseKeyStore.create({ database }); + const keyStore = await DatabaseKeyStore.create({ + database: await database.getClient(), + }); const tokenIssuer = new TokenFactory({ issuer: authUrl, keyStore, diff --git a/plugins/auth-backend/src/service/standaloneServer.ts b/plugins/auth-backend/src/service/standaloneServer.ts index 71771340a5..d00e7d523d 100644 --- a/plugins/auth-backend/src/service/standaloneServer.ts +++ b/plugins/auth-backend/src/service/standaloneServer.ts @@ -53,7 +53,11 @@ export async function startStandaloneServer( const router = await createRouter({ logger, config, - database, + database: { + async getClient() { + return database; + }, + }, discovery, }); diff --git a/plugins/catalog-backend/migrations/20201007201501_index_entity_search.js b/plugins/catalog-backend/migrations/20201007201501_index_entity_search.js new file mode 100644 index 0000000000..b902073e64 --- /dev/null +++ b/plugins/catalog-backend/migrations/20201007201501_index_entity_search.js @@ -0,0 +1,37 @@ +/* + * Copyright 2020 Spotify AB + * + * 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. + */ + +// @ts-check + +/** + * @param {import('knex')} knex + */ +exports.up = async function up(knex) { + await knex.schema.alterTable('entities_search', table => { + table.index(['key'], 'entities_search_key'); + table.index(['value'], 'entities_search_value'); + }); +}; + +/** + * @param {import('knex')} knex + */ +exports.down = async function down(knex) { + await knex.schema.alterTable('entities_search', table => { + table.dropIndex('', 'entities_search_key'); + table.dropIndex('', 'entities_search_value'); + }); +}; diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index f16f629c44..3d14563524 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -31,12 +31,13 @@ "express": "^4.17.1", "express-promise-router": "^3.0.3", "fs-extra": "^9.0.0", - "git-url-parse": "^11.2.0", + "git-url-parse": "^11.3.0", "knex": "^0.21.1", "ldapjs": "^2.2.0", "lodash": "^4.17.15", "morgan": "^1.10.0", "node-fetch": "^2.6.0", + "p-limit": "^3.0.2", "sqlite3": "^5.0.0", "uuid": "^8.0.0", "winston": "^3.2.1", diff --git a/plugins/catalog-backend/src/catalog/CoalescedEntitiesCatalog.test.ts b/plugins/catalog-backend/src/catalog/CoalescedEntitiesCatalog.test.ts deleted file mode 100644 index 1cfea23e43..0000000000 --- a/plugins/catalog-backend/src/catalog/CoalescedEntitiesCatalog.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * 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, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; -import { Logger } from 'winston'; -import { CoalescedEntitiesCatalog } from './CoalescedEntitiesCatalog'; -import { EntitiesCatalog } from './types'; - -describe('CoalescedEntitiesCatalog', () => { - const e1: Entity = { - apiVersion: 'a', - kind: 'k', - metadata: { name: 'n1' }, - }; - - const e2: Entity = { - apiVersion: 'a', - kind: 'k', - metadata: { name: 'n2' }, - }; - - const c1: jest.Mocked = { - entities: jest.fn(), - entityByUid: jest.fn(), - entityByName: jest.fn(), - addOrUpdateEntity: jest.fn(), - removeEntityByUid: jest.fn(), - }; - - const c2: jest.Mocked = { - entities: jest.fn(), - entityByUid: jest.fn(), - entityByName: jest.fn(), - addOrUpdateEntity: jest.fn(), - removeEntityByUid: jest.fn(), - }; - - const mockLogger = { - warn: jest.fn(), - }; - const logger = (mockLogger as unknown) as Logger; - - beforeEach(() => { - jest.resetAllMocks(); - }); - - describe('entities', () => { - it('flattens results from multiple sources', async () => { - c1.entities.mockResolvedValueOnce([e1]); - c2.entities.mockResolvedValueOnce([e2]); - const catalog = new CoalescedEntitiesCatalog([c1, c2], logger); - await expect(catalog.entities()).resolves.toEqual( - expect.arrayContaining([e1, e2]), - ); - expect(c1.entities).toBeCalledTimes(1); - expect(c2.entities).toBeCalledTimes(1); - }); - - it('logs an error if any source throws', async () => { - c1.entities.mockResolvedValueOnce([e1]); - c2.entities.mockRejectedValueOnce(new Error('boo')); - const catalog = new CoalescedEntitiesCatalog([c1, c2], logger); - await expect(catalog.entities()).resolves.toEqual([e1]); - expect(c1.entities).toBeCalledTimes(1); - expect(c2.entities).toBeCalledTimes(1); - expect(mockLogger.warn).toBeCalledWith(expect.stringMatching(/boo/)); - }); - }); - - describe('entityByUid', () => { - it('returns the first non-undefined result', async () => { - c1.entityByUid.mockResolvedValueOnce(undefined); - c2.entityByUid.mockResolvedValueOnce(e2); - const catalog = new CoalescedEntitiesCatalog([c1, c2], logger); - await expect(catalog.entityByUid('e2')).resolves.toBe(e2); - expect(c1.entityByUid).toBeCalledTimes(1); - expect(c2.entityByUid).toBeCalledTimes(1); - }); - - it('returns undefined if all results were undefined', async () => { - c1.entityByUid.mockResolvedValueOnce(undefined); - c2.entityByUid.mockResolvedValueOnce(undefined); - const catalog = new CoalescedEntitiesCatalog([c1, c2], logger); - await expect(catalog.entityByUid('e2')).resolves.toBeUndefined(); - expect(c1.entityByUid).toBeCalledTimes(1); - expect(c2.entityByUid).toBeCalledTimes(1); - }); - - it('logs an error if any source throws', async () => { - c1.entityByUid.mockResolvedValueOnce(e1); - c2.entityByUid.mockRejectedValueOnce(new Error('boo')); - const catalog = new CoalescedEntitiesCatalog([c1, c2], logger); - await expect(catalog.entityByUid('e2')).resolves.toBe(e1); - expect(c1.entityByUid).toBeCalledTimes(1); - expect(c2.entityByUid).toBeCalledTimes(1); - expect(mockLogger.warn).toBeCalledWith(expect.stringMatching(/boo/)); - }); - }); - - describe('entityByName', () => { - it('returns the first non-undefined result', async () => { - c1.entityByName.mockResolvedValueOnce(undefined); - c2.entityByName.mockResolvedValueOnce(e2); - const catalog = new CoalescedEntitiesCatalog([c1, c2], logger); - await expect( - catalog.entityByName({ - kind: 'k', - namespace: ENTITY_DEFAULT_NAMESPACE, - name: 'n2', - }), - ).resolves.toBe(e2); - expect(c1.entityByName).toBeCalledTimes(1); - expect(c2.entityByName).toBeCalledTimes(1); - }); - - it('returns undefined if all results were undefined', async () => { - c1.entityByName.mockResolvedValueOnce(undefined); - c2.entityByName.mockResolvedValueOnce(undefined); - const catalog = new CoalescedEntitiesCatalog([c1, c2], logger); - await expect( - catalog.entityByName({ - kind: 'k', - namespace: ENTITY_DEFAULT_NAMESPACE, - name: 'n2', - }), - ).resolves.toBeUndefined(); - expect(c1.entityByName).toBeCalledTimes(1); - expect(c2.entityByName).toBeCalledTimes(1); - }); - - it('logs an error if any source throws', async () => { - c1.entityByName.mockResolvedValueOnce(e1); - c2.entityByName.mockRejectedValueOnce(new Error('boo')); - const catalog = new CoalescedEntitiesCatalog([c1, c2], logger); - await expect( - catalog.entityByName({ - kind: 'k', - namespace: ENTITY_DEFAULT_NAMESPACE, - name: 'n2', - }), - ).resolves.toBe(e1); - expect(c1.entityByName).toBeCalledTimes(1); - expect(c2.entityByName).toBeCalledTimes(1); - expect(mockLogger.warn).toBeCalledWith(expect.stringMatching(/boo/)); - }); - }); -}); diff --git a/plugins/catalog-backend/src/catalog/CoalescedEntitiesCatalog.ts b/plugins/catalog-backend/src/catalog/CoalescedEntitiesCatalog.ts deleted file mode 100644 index 7eaf1868cb..0000000000 --- a/plugins/catalog-backend/src/catalog/CoalescedEntitiesCatalog.ts +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * 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, EntityName } from '@backstage/catalog-model'; -import { Logger } from 'winston'; -import { EntityFilters } from '../database'; -import { EntitiesCatalog } from './types'; - -/** - * A simple coalescing catalog wrapper, that acts as a front for collecting - * catalog data from multiple sources. - * - * One possible usage could be to have this as a front to both a - * DatabaseEntitiesCatalog that holds Component kinds, and another company- - * specific catalog that is a thin wrapper on top of LDAP that supplies Group - * and User entities. That way you'll get a coherent view of two very different - * entity sources. - * - * This is mainly meant as a functional example, and you may want to provide - * your own more specialized collector if you have this distinct need. This - * one does not support adding/updating entities through the API for example. - * A more competent implementation may direct the writes to different catalogs - * based on entity kind or similar. - */ -export class CoalescedEntitiesCatalog implements EntitiesCatalog { - private inner: EntitiesCatalog[]; - private logger: Logger; - - constructor(inner: EntitiesCatalog[], logger: Logger) { - this.inner = inner; - this.logger = logger; - } - - async entities(filters?: EntityFilters): Promise { - const ops = this.inner.map(async catalog => { - try { - return await catalog.entities(filters); - } catch (e) { - this.logger.warn(`Inner entities call failed, ${e}`); - return []; - } - }); - - const results = await Promise.all(ops); - return results.flat(); - } - - async entityByUid(uid: string): Promise { - const ops = this.inner.map(async catalog => { - try { - return await catalog.entityByUid(uid); - } catch (e) { - this.logger.warn(`Inner entityByUid call failed, ${e}`); - return undefined; - } - }); - - const results = await Promise.all(ops); - return results.find(Boolean); - } - - async entityByName(name: EntityName): Promise { - const ops = this.inner.map(async catalog => { - try { - return await catalog.entityByName(name); - } catch (e) { - this.logger.warn(`Inner entityByName call failed, ${e}`); - return undefined; - } - }); - - const results = await Promise.all(ops); - return results.find(Boolean); - } - - addOrUpdateEntity(): Promise { - throw new Error('Method not implemented.'); - } - - removeEntityByUid(): Promise { - throw new Error('Method not implemented.'); - } -} diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts index b94365a9ba..8ef685e6b0 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts @@ -24,12 +24,12 @@ describe('DatabaseEntitiesCatalog', () => { beforeAll(() => { db = { transaction: jest.fn(), - addEntity: jest.fn(), + addEntities: jest.fn(), updateEntity: jest.fn(), entities: jest.fn(), entityByName: jest.fn(), entityByUid: jest.fn(), - removeEntity: jest.fn(), + removeEntityByUid: jest.fn(), addLocation: jest.fn(), removeLocation: jest.fn(), location: jest.fn(), @@ -56,7 +56,7 @@ describe('DatabaseEntitiesCatalog', () => { }; db.entities.mockResolvedValue([]); - db.addEntity.mockResolvedValue({ entity }); + db.addEntities.mockResolvedValue([{ entity }]); const catalog = new DatabaseEntitiesCatalog(db); const result = await catalog.addOrUpdateEntity(entity); @@ -67,7 +67,7 @@ describe('DatabaseEntitiesCatalog', () => { namespace: 'd', name: 'c', }); - expect(db.addEntity).toHaveBeenCalledTimes(1); + expect(db.addEntities).toHaveBeenCalledTimes(1); expect(result).toBe(entity); }); diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts index 750d728c67..2575411e7c 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts @@ -72,13 +72,25 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { existing.entity.metadata.generation, ); } else { - response = await this.database.addEntity(tx, { locationId, entity }); + const added = await this.database.addEntities(tx, [ + { locationId, entity }, + ]); + response = added[0]; } return response.entity; }); } + async addEntities(entities: Entity[], locationId?: string): Promise { + await this.database.transaction(async tx => { + await this.database.addEntities( + tx, + entities.map(entity => ({ locationId, entity })), + ); + }); + } + async removeEntityByUid(uid: string): Promise { return await this.database.transaction(async tx => { const entityResponse = await this.database.entityByUid(tx, uid); @@ -96,7 +108,10 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { ]) : [entityResponse]; for (const dbResponse of colocatedEntities) { - await this.database.removeEntity(tx, dbResponse?.entity.metadata.uid!); + await this.database.removeEntityByUid( + tx, + dbResponse?.entity.metadata.uid!, + ); } if (entityResponse.locationId) { diff --git a/plugins/catalog-backend/src/catalog/StaticEntitiesCatalog.ts b/plugins/catalog-backend/src/catalog/StaticEntitiesCatalog.ts deleted file mode 100644 index 65fbfb9071..0000000000 --- a/plugins/catalog-backend/src/catalog/StaticEntitiesCatalog.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * 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, EntityName, getEntityName } from '@backstage/catalog-model'; -import lodash from 'lodash'; -import type { EntitiesCatalog } from './types'; - -export class StaticEntitiesCatalog implements EntitiesCatalog { - private _entities: Entity[]; - - constructor(entities: Entity[]) { - this._entities = entities; - } - - async entities(): Promise { - return lodash.cloneDeep(this._entities); - } - - async entityByUid(uid: string): Promise { - const item = this._entities.find(e => uid === e.metadata.uid); - return item ? lodash.cloneDeep(item) : undefined; - } - - async entityByName(name: EntityName): Promise { - const item = this._entities.find(e => { - const candidate = getEntityName(e); - return ( - name.kind.toLowerCase() === candidate.kind.toLowerCase() && - name.namespace.toLowerCase() === candidate.namespace.toLowerCase() && - name.name.toLowerCase() === candidate.name.toLowerCase() - ); - }); - return item ? lodash.cloneDeep(item) : undefined; - } - - async addOrUpdateEntity(): Promise { - throw new Error('Not supported'); - } - - async removeEntityByUid(): Promise { - throw new Error('Not supported'); - } -} diff --git a/plugins/catalog-backend/src/catalog/index.ts b/plugins/catalog-backend/src/catalog/index.ts index 308078b1fc..ce429327a0 100644 --- a/plugins/catalog-backend/src/catalog/index.ts +++ b/plugins/catalog-backend/src/catalog/index.ts @@ -16,5 +16,4 @@ export { DatabaseEntitiesCatalog } from './DatabaseEntitiesCatalog'; export { DatabaseLocationsCatalog } from './DatabaseLocationsCatalog'; -export { StaticEntitiesCatalog } from './StaticEntitiesCatalog'; export type { EntitiesCatalog, LocationsCatalog } from './types'; diff --git a/plugins/catalog-backend/src/catalog/types.ts b/plugins/catalog-backend/src/catalog/types.ts index 37618a2440..5d84cbcebc 100644 --- a/plugins/catalog-backend/src/catalog/types.ts +++ b/plugins/catalog-backend/src/catalog/types.ts @@ -26,6 +26,7 @@ export type EntitiesCatalog = { entityByUid(uid: string): Promise; entityByName(name: EntityName): Promise; addOrUpdateEntity(entity: Entity, locationId?: string): Promise; + addEntities(entities: Entity[], locationId?: string): Promise; removeEntityByUid(uid: string): Promise; }; diff --git a/plugins/catalog-backend/src/database/CommonDatabase.test.ts b/plugins/catalog-backend/src/database/CommonDatabase.test.ts index 2bacec4026..840af74825 100644 --- a/plugins/catalog-backend/src/database/CommonDatabase.test.ts +++ b/plugins/catalog-backend/src/database/CommonDatabase.test.ts @@ -17,12 +17,12 @@ import { ConflictError } from '@backstage/backend-common'; import type { Entity, Location } from '@backstage/catalog-model'; import { DatabaseManager } from './DatabaseManager'; -import { Database, DatabaseLocationUpdateLogStatus } from './types'; import type { DbEntityRequest, DbEntityResponse, DbLocationsRowWithStatus, } from './types'; +import { Database, DatabaseLocationUpdateLogStatus } from './types'; const bootstrapLocation = { id: expect.any(String), @@ -135,36 +135,99 @@ describe('CommonDatabase', () => { await expect(db.location(location.id)).rejects.toThrow(/Found no location/); }); - describe('addEntity', () => { - it('happy path: adds entity to empty database', async () => { - const added = await db.transaction(tx => db.addEntity(tx, entityRequest)); - expect(added).toStrictEqual(entityResponse); - expect(added.entity.metadata.generation).toBe(1); + describe('addEntities', () => { + it('happy path: adds entities to empty database', async () => { + const result = await db.transaction(tx => + db.addEntities(tx, [entityRequest]), + ); + expect(result).toEqual([entityResponse]); }); it('rejects adding the same-named entity twice', async () => { - await db.transaction(tx => db.addEntity(tx, entityRequest)); + const req: DbEntityRequest[] = [ + { + entity: { + apiVersion: 'av1', + kind: 'k1', + metadata: { name: 'n1', namespace: 'ns1' }, + }, + }, + { + entity: { + apiVersion: 'av1', + kind: 'k1', + metadata: { name: 'n1', namespace: 'ns1' }, + }, + }, + ]; await expect( - db.transaction(tx => db.addEntity(tx, entityRequest)), + db.transaction(tx => db.addEntities(tx, req)), ).rejects.toThrow(ConflictError); }); it('rejects adding the almost-same-namespace entity twice', async () => { - entityRequest.entity.metadata.namespace = undefined; - await db.transaction(tx => db.addEntity(tx, entityRequest)); - entityRequest.entity.metadata.namespace = ''; + const req: DbEntityRequest[] = [ + { + entity: { + apiVersion: 'av1', + kind: 'k1', + metadata: { name: 'n1', namespace: 'ns1' }, + }, + }, + { + entity: { + apiVersion: 'av1', + kind: 'k1', + metadata: { name: 'n1', namespace: 'nS1' }, + }, + }, + ]; await expect( - db.transaction(tx => db.addEntity(tx, entityRequest)), + db.transaction(tx => db.addEntities(tx, req)), ).rejects.toThrow(ConflictError); }); it('accepts adding the same-named entity twice if on different namespaces', async () => { - entityRequest.entity.metadata.namespace = 'namespace1'; - await db.transaction(tx => db.addEntity(tx, entityRequest)); - entityRequest.entity.metadata.namespace = 'namespace2'; + const req: DbEntityRequest[] = [ + { + entity: { + apiVersion: 'av1', + kind: 'k1', + metadata: { name: 'n1', namespace: 'ns1' }, + }, + }, + { + entity: { + apiVersion: 'av1', + kind: 'k1', + metadata: { name: 'n1', namespace: 'ns2' }, + }, + }, + ]; await expect( - db.transaction(tx => db.addEntity(tx, entityRequest)), - ).resolves.toBeDefined(); + db.transaction(tx => db.addEntities(tx, req)), + ).resolves.toEqual([ + { + entity: expect.objectContaining({ + metadata: expect.objectContaining({ + namespace: 'ns1', + uid: expect.any(String), + etag: expect.any(String), + generation: expect.any(Number), + }), + }), + }, + { + entity: expect.objectContaining({ + metadata: expect.objectContaining({ + namespace: 'ns2', + uid: expect.any(String), + etag: expect.any(String), + generation: expect.any(Number), + }), + }), + }, + ]); }); }); @@ -216,7 +279,9 @@ describe('CommonDatabase', () => { describe('updateEntity', () => { it('can read and no-op-update an entity', async () => { - const added = await db.transaction(tx => db.addEntity(tx, entityRequest)); + const [added] = await db.transaction(tx => + db.addEntities(tx, [entityRequest]), + ); const updated = await db.transaction(tx => db.updateEntity(tx, { entity: added.entity }), ); @@ -233,7 +298,9 @@ describe('CommonDatabase', () => { }); it('can update name if uid matches', async () => { - const added = await db.transaction(tx => db.addEntity(tx, entityRequest)); + const [added] = await db.transaction(tx => + db.addEntities(tx, [entityRequest]), + ); added.entity.metadata.name! = 'new!'; const updated = await db.transaction(tx => db.updateEntity(tx, { entity: added.entity }), @@ -242,7 +309,9 @@ describe('CommonDatabase', () => { }); it('fails to update an entity if etag does not match', async () => { - const added = await db.transaction(tx => db.addEntity(tx, entityRequest)); + const [added] = await db.transaction(tx => + db.addEntities(tx, [entityRequest]), + ); await expect( db.transaction(tx => db.updateEntity(tx, { entity: added.entity }, 'garbage'), @@ -251,7 +320,9 @@ describe('CommonDatabase', () => { }); it('fails to update an entity if generation does not match', async () => { - const added = await db.transaction(tx => db.addEntity(tx, entityRequest)); + const [added] = await db.transaction(tx => + db.addEntities(tx, [entityRequest]), + ); await expect( db.transaction(tx => db.updateEntity(tx, { entity: added.entity }, undefined, 1e20), @@ -274,8 +345,7 @@ describe('CommonDatabase', () => { spec: { c: null }, }; await db.transaction(async tx => { - await db.addEntity(tx, { entity: e1 }); - await db.addEntity(tx, { entity: e2 }); + await db.addEntities(tx, [{ entity: e1 }, { entity: e2 }]); }); const result = await db.transaction(async tx => db.entities(tx, [])); expect(result.length).toEqual(2); @@ -311,9 +381,10 @@ describe('CommonDatabase', () => { ]; await db.transaction(async tx => { - for (const entity of entities) { - await db.addEntity(tx, { entity }); - } + await db.addEntities( + tx, + entities.map(entity => ({ entity })), + ); }); await expect( @@ -349,9 +420,10 @@ describe('CommonDatabase', () => { ]; await db.transaction(async tx => { - for (const entity of entities) { - await db.addEntity(tx, { entity }); - } + await db.addEntities( + tx, + entities.map(entity => ({ entity })), + ); }); const rows = await db.transaction(async tx => @@ -398,9 +470,10 @@ describe('CommonDatabase', () => { ]; await db.transaction(async tx => { - for (const entity of entities) { - await db.addEntity(tx, { entity }); - } + await db.addEntities( + tx, + entities.map(entity => ({ entity })), + ); }); const rows = await db.transaction(async tx => @@ -446,9 +519,10 @@ describe('CommonDatabase', () => { ]; await db.transaction(async tx => { - for (const entity of entities) { - await db.addEntity(tx, { entity }); - } + await db.addEntities( + tx, + entities.map(entity => ({ entity })), + ); }); const e1 = await db.transaction(async tx => diff --git a/plugins/catalog-backend/src/database/CommonDatabase.ts b/plugins/catalog-backend/src/database/CommonDatabase.ts index 890a64781a..49b36e0933 100644 --- a/plugins/catalog-backend/src/database/CommonDatabase.ts +++ b/plugins/catalog-backend/src/database/CommonDatabase.ts @@ -45,6 +45,12 @@ import type { EntityFilters, } from './types'; +// The number of items that are sent per batch to the database layer, when +// doing .batchInsert calls to knex. This needs to be low enough to not cause +// errors in the underlying engine due to exceeding query limits, but large +// enough to get the speed benefits. +const BATCH_SIZE = 50; + /** * The core database implementation. */ @@ -100,6 +106,52 @@ export class CommonDatabase implements Database { return { locationId: request.locationId, entity: newEntity }; } + async addEntities( + txOpaque: unknown, + request: DbEntityRequest[], + ): Promise { + const tx = txOpaque as Knex.Transaction; + + const result: DbEntityResponse[] = []; + const entityRows: DbEntitiesRow[] = []; + const searchRows: DbEntitiesSearchRow[] = []; + + for (const { entity, locationId } of request) { + if (entity.metadata.uid !== undefined) { + throw new InputError('May not specify uid for new entities'); + } else if (entity.metadata.etag !== undefined) { + throw new InputError('May not specify etag for new entities'); + } else if (entity.metadata.generation !== undefined) { + throw new InputError('May not specify generation for new entities'); + } + + const newEntity = { + ...entity, + metadata: { + ...entity.metadata, + uid: generateEntityUid(), + etag: generateEntityEtag(), + generation: 1, + }, + }; + + result.push({ entity: newEntity, locationId }); + entityRows.push(this.toEntityRow(locationId, newEntity)); + searchRows.push(...buildEntitySearch(newEntity.metadata.uid, newEntity)); + } + + await tx.batchInsert('entities', entityRows, BATCH_SIZE); + await tx('entities_search') + .whereIn( + 'entity_id', + entityRows.map(r => r.id), + ) + .del(); + await tx.batchInsert('entities_search', searchRows, BATCH_SIZE); + + return result; + } + async updateEntity( txOpaque: unknown, request: DbEntityRequest, @@ -165,10 +217,10 @@ export class CommonDatabase implements Database { ): Promise { const tx = txOpaque as Knex.Transaction; - let builder = tx('entities'); - for (const [indexU, filter] of (filters ?? []).entries()) { - const index = Number(indexU); - const key = filter.key.toLowerCase().replace(/\*/g, '%'); + let entitiesQuery = tx('entities'); + + for (const filter of filters || []) { + const key = filter.key.toLowerCase().replace(/[*]/g, '%'); const keyOp = filter.key.includes('*') ? 'like' : '='; let matchNulls = false; @@ -179,36 +231,54 @@ export class CommonDatabase implements Database { if (!value) { matchNulls = true; } else if (value.includes('*')) { - matchLike.push(value.toLowerCase().replace(/\*/g, '%')); + matchLike.push(value.toLowerCase().replace(/[*]/g, '%')); } else { matchIn.push(value.toLowerCase()); } } - builder = builder - .leftOuterJoin(`entities_search as t${index}`, function joins() { - this.on('entities.id', '=', `t${index}.entity_id`); - this.andOn(`t${index}.key`, keyOp, tx.raw('?', [key])); - }) - .where(function rules() { - if (matchIn.length) { - this.orWhereIn(`t${index}.value`, matchIn); - } - if (matchLike.length) { - for (const x of matchLike) { - this.orWhere(`t${index}.value`, 'like', tx.raw('?', [x])); + // NOTE(freben): This used to be a set of OUTER JOIN, which may seem to + // make a lot of sense. However, it had abysmal performance on sqlite + // when datasets grew large, so we're using IN instead. + const matchQuery = tx('entities_search') + .select('entity_id') + .where(function keyFilter() { + this.andWhere('key', keyOp, key); + this.andWhere(function valueFilter() { + if (matchIn.length === 1) { + this.orWhere({ value: matchIn[0] }); + } else if (matchIn.length > 1) { + this.orWhereIn('value', matchIn); } - } - if (matchNulls) { - this.orWhereNull(`t${index}.value`); - } + if (matchLike.length) { + for (const x of matchLike) { + this.orWhere('value', 'like', tx.raw('?', [x])); + } + } + if (matchNulls) { + // Match explicit nulls, and then handle absence separately below + this.orWhereNull('value'); + } + }); }); + + // Handle absence as nulls as well + entitiesQuery = entitiesQuery.andWhere(function match() { + this.whereIn('id', matchQuery); + if (matchNulls) { + this.orWhereNotIn( + 'id', + tx('entities_search') + .select('entity_id') + .where('key', keyOp, key), + ); + } + }); } - const rows = await builder + const rows = await entitiesQuery .select('entities.*') - .orderBy('full_name', 'asc') - .groupBy('id'); + .orderBy('full_name', 'asc'); return rows.map(row => this.toEntityResponse(row)); } @@ -249,7 +319,7 @@ export class CommonDatabase implements Database { return this.toEntityResponse(rows[0]); } - async removeEntity(txOpaque: unknown, uid: string): Promise { + async removeEntityByUid(txOpaque: unknown, uid: string): Promise { const tx = txOpaque as Knex.Transaction; const result = await tx('entities').where({ id: uid }).del(); diff --git a/plugins/catalog-backend/src/database/types.ts b/plugins/catalog-backend/src/database/types.ts index 5a577f2223..20cc123c0a 100644 --- a/plugins/catalog-backend/src/database/types.ts +++ b/plugins/catalog-backend/src/database/types.ts @@ -89,13 +89,15 @@ export type Database = { transaction(fn: (tx: unknown) => Promise): Promise; /** - * Adds a new entity to the catalog. + * Adds a set of new entities to the catalog. * * @param tx An ongoing transaction - * @param request The entity being added - * @returns The added entity, with uid, etag and generation set + * @param request The entities being added */ - addEntity(tx: unknown, request: DbEntityRequest): Promise; + addEntities( + tx: unknown, + request: DbEntityRequest[], + ): Promise; /** * Updates an existing entity in the catalog. @@ -132,7 +134,7 @@ export type Database = { entityByUid(tx: unknown, uid: string): Promise; - removeEntity(tx: unknown, uid: string): Promise; + removeEntityByUid(tx: unknown, uid: string): Promise; addLocation(location: Location): Promise; diff --git a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts index 32fcc27909..15d9168732 100644 --- a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts +++ b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts @@ -39,6 +39,7 @@ describe('HigherOrderOperations', () => { entityByUid: jest.fn(), entityByName: jest.fn(), addOrUpdateEntity: jest.fn(), + addEntities: jest.fn(), removeEntityByUid: jest.fn(), }; locationsCatalog = { @@ -191,8 +192,8 @@ describe('HigherOrderOperations', () => { entities: [{ entity: desc, location }], errors: [], }); - entitiesCatalog.entityByName.mockResolvedValue(undefined); - entitiesCatalog.addOrUpdateEntity.mockResolvedValue(desc); + entitiesCatalog.entities.mockResolvedValue([]); + entitiesCatalog.addEntities.mockResolvedValue(undefined); await expect( higherOrderOperation.refreshAllLocations(), @@ -204,18 +205,19 @@ describe('HigherOrderOperations', () => { type: 'some', target: 'thing', }); - expect(entitiesCatalog.entityByName).toHaveBeenCalledTimes(1); - expect(entitiesCatalog.entityByName).toHaveBeenNthCalledWith(1, { - kind: 'Component', - namespace: ENTITY_DEFAULT_NAMESPACE, - name: 'c1', - }); - expect(entitiesCatalog.addOrUpdateEntity).toHaveBeenCalledTimes(1); - expect(entitiesCatalog.addOrUpdateEntity).toHaveBeenNthCalledWith( + expect(entitiesCatalog.entities).toHaveBeenCalledTimes(1); + expect(entitiesCatalog.entities).toHaveBeenNthCalledWith( 1, - expect.objectContaining({ - metadata: expect.objectContaining({ name: 'c1' }), - }), + expect.arrayContaining([ + { key: 'kind', values: ['Component'] }, + { key: 'metadata.namespace', values: [ENTITY_DEFAULT_NAMESPACE] }, + { key: 'metadata.name', values: ['c1'] }, + ]), + ); + expect(entitiesCatalog.addEntities).toHaveBeenCalledTimes(1); + expect(entitiesCatalog.addEntities).toHaveBeenNthCalledWith( + 1, + [expect.objectContaining({ metadata: { name: 'c1' } })], '123', ); }); @@ -245,8 +247,8 @@ describe('HigherOrderOperations', () => { entities: [{ entity: desc, location }], errors: [], }); - entitiesCatalog.entityByName.mockResolvedValue(undefined); - entitiesCatalog.addOrUpdateEntity.mockResolvedValue(desc); + entitiesCatalog.entities.mockResolvedValue([]); + entitiesCatalog.addEntities.mockResolvedValue(undefined); await expect( higherOrderOperation.refreshAllLocations(), diff --git a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts index 7232b876c7..737166436c 100644 --- a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts +++ b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { InputError } from '@backstage/backend-common'; +import { ConflictError, InputError } from '@backstage/backend-common'; import { Entity, entityHasChanges, @@ -23,15 +23,37 @@ import { LocationSpec, serializeEntityRef, } from '@backstage/catalog-model'; +import { chunk, groupBy } from 'lodash'; +import limiterFactory from 'p-limit'; import { v4 as uuidv4 } from 'uuid'; import { Logger } from 'winston'; import { EntitiesCatalog, LocationsCatalog } from '../catalog'; +import { durationText } from '../util/timing'; import { AddLocationResult, HigherOrderOperation, LocationReader, } from './types'; +type BatchContext = { + kind: string; + namespace: string; + location: Location; +}; + +// Some locations return tens or hundreds of thousands of entities. To make +// those payloads more manageable, we break work apart in batches of this +// many entities and write them to storage per batch. +const BATCH_SIZE = 100; + +// When writing large batches, there's an increasing chance of contention in +// the form of conflicts where we compete with other writes. Each batch gets +// this many attempts at being written before giving up. +const BATCH_ATTEMPTS = 3; + +// The number of batches that may be ongoing at the same time. +const BATCH_CONCURRENCY = 3; + /** * Placeholder for operations that span several catalogs and/or stretches out * in time. @@ -88,7 +110,7 @@ export class HigherOrderOperations implements HigherOrderOperation { if (readerOutput.errors.length) { const item = readerOutput.errors[0]; throw new InputError( - `Failed to read location ${item.location.type} ${item.location.target}, ${item.error}`, + `Failed to read location ${item.location.type}:${item.location.target}, ${item.error}`, ); } @@ -121,85 +143,209 @@ export class HigherOrderOperations implements HigherOrderOperation { * without changes. */ async refreshAllLocations(): Promise { - const startTimestamp = new Date().valueOf(); + const startTimestamp = process.hrtime(); this.logger.info('Beginning locations refresh'); const locations = await this.locationsCatalog.locations(); this.logger.info(`Visiting ${locations.length} locations`); for (const { data: location } of locations) { - this.logger.debug( - `Refreshing location id="${location.id}" type="${location.type}" target="${location.target}"`, + this.logger.info( + `Refreshing location ${location.type}:${location.target}`, ); try { await this.refreshSingleLocation(location); await this.locationsCatalog.logUpdateSuccess(location.id, undefined); } catch (e) { - this.logger.debug( - `Failed to refresh location id="${location.id}" type="${location.type}" target="${location.target}", ${e}`, + this.logger.warn( + `Failed to refresh location ${location.type}:${location.target}, ${e}`, ); await this.locationsCatalog.logUpdateFailure(location.id, e); } } - const endTimestamp = new Date().valueOf(); - const duration = ((endTimestamp - startTimestamp) / 1000).toFixed(1); - this.logger.debug(`Completed locations refresh in ${duration} seconds`); + this.logger.info( + `Completed locations refresh in ${durationText(startTimestamp)}`, + ); } // Performs a full refresh of a single location private async refreshSingleLocation(location: Location) { + let startTimestamp = process.hrtime(); + const readerOutput = await this.locationReader.read({ type: location.type, target: location.target, }); for (const item of readerOutput.errors) { - this.logger.debug( - `Failed item in location type="${item.location.type}" target="${item.location.target}", ${item.error}`, + this.logger.warn( + `Failed item in location ${item.location.type}:${item.location.target}, ${item.error}`, ); } this.logger.info( - `Read ${readerOutput.entities.length} entities from location ${location.type} ${location.target}`, + `Read ${readerOutput.entities.length} entities from location ${ + location.type + }:${location.target} in ${durationText(startTimestamp)}`, ); - const startTimestamp = process.hrtime(); - for (const item of readerOutput.entities) { - const { entity } = item; + startTimestamp = process.hrtime(); - try { - const previous = await this.entitiesCatalog.entityByName( - getEntityName(entity), - ); + await this.batchAddOrUpdateEntities( + readerOutput.entities.map(e => e.entity), + location, + ); - if (!previous) { - await this.entitiesCatalog.addOrUpdateEntity(entity, location.id); - } else if (entityHasChanges(previous, entity)) { - await this.entitiesCatalog.addOrUpdateEntity(entity, location.id); - } + this.logger.info( + `Wrote ${readerOutput.entities.length} entities from location ${ + location.type + }:${location.target} in ${durationText(startTimestamp)}`, + ); + } - await this.locationsCatalog.logUpdateSuccess( - location.id, - entity.metadata.name, - ); - } catch (error) { - this.logger.info( - `Failed refresh of entity ${serializeEntityRef(entity)}, ${error}`, - ); + /** + * Writes a number of entities efficiently to storage. + * + * @param entities Some entities + * @param location The location that they all belong to + */ + async batchAddOrUpdateEntities(entities: Entity[], location: Location) { + // Group the entities by unique kind+namespace combinations + const entitiesByKindAndNamespace = groupBy(entities, entity => { + const name = getEntityName(entity); + return `${name.kind}:${name.namespace}`.toLowerCase(); + }); - await this.locationsCatalog.logUpdateFailure( - location.id, - error, - entity.metadata.name, + const limiter = limiterFactory(BATCH_CONCURRENCY); + const tasks: Promise[] = []; + + for (const groupEntities of Object.values(entitiesByKindAndNamespace)) { + const { kind, namespace } = getEntityName(groupEntities[0]); + + // Go through the new entities in reasonable chunk sizes (sometimes, + // sources produce tens of thousands of entities, and those are too large + // batch sizes to reasonably send to the database) + for (const batch of chunk(groupEntities, BATCH_SIZE)) { + tasks.push( + limiter(async () => { + const first = serializeEntityRef(batch[0]); + const last = serializeEntityRef(batch[batch.length - 1]); + this.logger.debug( + `Considering batch ${first}-${last} (${batch.length} entries)`, + ); + + // Retry the batch write a few times to deal with contention + const context = { kind, namespace, location }; + for (let attempt = 1; attempt <= BATCH_ATTEMPTS; ++attempt) { + try { + const { toAdd, toUpdate } = await this.analyzeBatch( + batch, + context, + ); + if (toAdd.length) await this.batchAdd(toAdd, context); + if (toUpdate.length) await this.batchUpdate(toUpdate, context); + break; + } catch (e) { + if (e instanceof ConflictError && attempt < BATCH_ATTEMPTS) { + this.logger.warn( + `Failed to write batch at attempt ${attempt}/${BATCH_ATTEMPTS}, ${e}`, + ); + } else { + throw e; + } + } + } + }), ); } } - const delta = process.hrtime(startTimestamp); - const durationMs = ((delta[0] * 1e9 + delta[1]) / 1e9).toFixed(1); - this.logger.info( - `Wrote ${readerOutput.entities.length} entities from location ${location.type} ${location.target} in ${durationMs} seconds`, + await Promise.all(tasks); + } + + // Given a batch of entities that were just read from a location, take them + // into consideration by comparing against the existing catalog entities and + // produce the list of entities to be added, and the list of entities to be + // updated + private async analyzeBatch( + newEntities: Entity[], + { kind, namespace }: BatchContext, + ): Promise<{ + toAdd: Entity[]; + toUpdate: Entity[]; + }> { + const markTimestamp = process.hrtime(); + + const names = newEntities.map(e => e.metadata.name); + const oldEntities = await this.entitiesCatalog.entities([ + { key: 'kind', values: [kind] }, + { key: 'metadata.namespace', values: [namespace] }, + { key: 'metadata.name', values: names }, + ]); + + const oldEntitiesByName = new Map( + oldEntities.map(e => [e.metadata.name, e]), + ); + + const toAdd: Entity[] = []; + const toUpdate: Entity[] = []; + + for (const newEntity of newEntities) { + const oldEntity = oldEntitiesByName.get(newEntity.metadata.name); + if (!oldEntity) { + toAdd.push(newEntity); + } else if (entityHasChanges(oldEntity, newEntity)) { + toUpdate.push(newEntity); + } + } + + this.logger.debug( + `Found ${toAdd.length} entities to add, ${ + toUpdate.length + } entities to update in ${durationText(markTimestamp)}`, + ); + + return { toAdd, toUpdate }; + } + + // Efficiently adds the given entities to storage, under the assumption that + // they do not conflict with any existing entities + private async batchAdd(entities: Entity[], { location }: BatchContext) { + const markTimestamp = process.hrtime(); + + await this.entitiesCatalog.addEntities(entities, location.id); + + // TODO(freben): Still not batched + for (const entity of entities) { + await this.locationsCatalog.logUpdateSuccess( + location.id, + entity.metadata.name, + ); + } + + this.logger.debug( + `Added ${entities.length} entities in ${durationText(markTimestamp)}`, + ); + } + + // Efficiently updates the given entities into storage, under the assumption + // that there already exist entities with the same names + private async batchUpdate(entities: Entity[], { location }: BatchContext) { + const markTimestamp = process.hrtime(); + + // TODO(freben): Still not batched + for (const entity of entities) { + await this.entitiesCatalog.addOrUpdateEntity(entity); + + await this.locationsCatalog.logUpdateSuccess( + location.id, + entity.metadata.name, + ); + } + + this.logger.debug( + `Updated ${entities.length} entities in ${durationText(markTimestamp)}`, ); } } diff --git a/plugins/catalog-backend/src/service/router.test.ts b/plugins/catalog-backend/src/service/router.test.ts index 32d7b6aa63..ecf4b0e614 100644 --- a/plugins/catalog-backend/src/service/router.test.ts +++ b/plugins/catalog-backend/src/service/router.test.ts @@ -35,6 +35,7 @@ describe('createRouter', () => { entityByUid: jest.fn(), entityByName: jest.fn(), addOrUpdateEntity: jest.fn(), + addEntities: jest.fn(), removeEntityByUid: jest.fn(), }; locationsCatalog = { diff --git a/plugins/catalog-backend/src/util/timing.ts b/plugins/catalog-backend/src/util/timing.ts new file mode 100644 index 0000000000..1abc446ab4 --- /dev/null +++ b/plugins/catalog-backend/src/util/timing.ts @@ -0,0 +1,31 @@ +/* + * Copyright 2020 Spotify AB + * + * 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. + */ + +/** + * Returns a string with the elapsed time since the start of an operation, + * with some human friendly precision, e.g. "133ms" or "14.5s". + * + * @param startTimestamp The timestamp (from process.hrtime()) at the start ot + * the operation + */ +export function durationText(startTimestamp: [number, number]): string { + const delta = process.hrtime(startTimestamp); + const seconds = delta[0] + delta[1] / 1e9; + if (seconds > 1) { + return `${seconds.toFixed(1)}s`; + } + return `${(seconds * 1000).toFixed(0)}ms`; +} diff --git a/plugins/circleci/src/components/Router.tsx b/plugins/circleci/src/components/Router.tsx index 41764b3d7d..5fe9a20eff 100644 --- a/plugins/circleci/src/components/Router.tsx +++ b/plugins/circleci/src/components/Router.tsx @@ -21,16 +21,14 @@ import { BuildWithStepsPage } from './BuildWithStepsPage/'; import { BuildsPage } from './BuildsPage'; import { CIRCLECI_ANNOTATION } from '../constants'; import { Entity } from '@backstage/catalog-model'; -import { WarningPanel } from '@backstage/core'; +import { MissingAnnotationEmptyState } from '@backstage/core'; export const isPluginApplicableToEntity = (entity: Entity) => Boolean(entity.metadata.annotations?.[CIRCLECI_ANNOTATION]); export const Router = ({ entity }: { entity: Entity }) => !isPluginApplicableToEntity(entity) ? ( - -
{CIRCLECI_ANNOTATION}
annotation is missing on the entity. -
+ ) : ( } /> diff --git a/plugins/cloudbuild/src/components/Router.tsx b/plugins/cloudbuild/src/components/Router.tsx index b2653a05f2..af4959f4be 100644 --- a/plugins/cloudbuild/src/components/Router.tsx +++ b/plugins/cloudbuild/src/components/Router.tsx @@ -20,7 +20,7 @@ import { rootRouteRef, buildRouteRef } from '../plugin'; import { WorkflowRunDetails } from './WorkflowRunDetails'; import { WorkflowRunsTable } from './WorkflowRunsTable'; import { CLOUDBUILD_ANNOTATION } from './useProjectName'; -import { WarningPanel } from '@backstage/core'; +import { MissingAnnotationEmptyState } from '@backstage/core'; export const isPluginApplicableToEntity = (entity: Entity) => Boolean(entity.metadata.annotations?.[CLOUDBUILD_ANNOTATION]); @@ -28,9 +28,7 @@ export const isPluginApplicableToEntity = (entity: Entity) => export const Router = ({ entity }: { entity: Entity }) => // TODO(shmidt-i): move warning to a separate standardized component !isPluginApplicableToEntity(entity) ? ( - -
{CLOUDBUILD_ANNOTATION}
annotation is missing on the entity. -
+ ) : ( , ): Promise; /** diff --git a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.test.tsx b/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.test.tsx index 6e6f935137..0600f89c60 100644 --- a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.test.tsx +++ b/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.test.tsx @@ -17,19 +17,18 @@ import React from 'react'; import { renderInTestApp } from '@backstage/test-utils'; import AlertActionCard from './AlertActionCard'; -import { AlertType, ProjectGrowthAlert } from '../../types'; -import { getAlertText } from '../../utils/alerts'; +import { ProjectGrowthAlert, ProjectGrowthData } from '../../types'; import { MockScrollProvider } from '../../utils/tests'; -const alert = { - id: AlertType.ProjectGrowth, +const data: ProjectGrowthData = { aggregation: [500000.8, 970502.8], project: 'test-project', periodStart: '2019-10-01', periodEnd: '2020-03-31', change: { ratio: 120, amount: 120000 }, products: [], -} as ProjectGrowthAlert; +}; +const alert = new ProjectGrowthAlert(data); describe('', () => { it('Renders an alert', async () => { @@ -40,11 +39,7 @@ describe('', () => { ); expect(rendered.getByText('1')).toBeInTheDocument(); - const text = getAlertText(alert); - expect(text).toBeDefined(); - if (text) { - expect(rendered.getByText(text.title)).toBeInTheDocument(); - expect(rendered.getByText(text.subtitle)).toBeInTheDocument(); - } + expect(rendered.getByText(alert.title)).toBeInTheDocument(); + expect(rendered.getByText(alert.subtitle)).toBeInTheDocument(); }); }); diff --git a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.tsx b/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.tsx index f568f999f8..a83c7bc7ce 100644 --- a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.tsx +++ b/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.tsx @@ -17,10 +17,9 @@ import React from 'react'; import { Avatar, Card, CardHeader } from '@material-ui/core'; import { useScroll } from '../../hooks'; import { Alert } from '../../types'; -import { getAlertText, getAlertNavigation } from '../../utils/alerts'; import { - useAlertActionCardStyles as useStyles, useAlertActionCardHeader as useHeaderStyles, + useAlertActionCardStyles as useStyles, } from '../../utils/styles'; type AlertActionCardProps = { @@ -29,9 +28,8 @@ type AlertActionCardProps = { }; const AlertActionCard = ({ alert, number }: AlertActionCardProps) => { - const { scrollIntoView } = useScroll(getAlertNavigation(alert, number)); + const { scrollIntoView } = useScroll(`alert-${number}`); const headerClasses = useHeaderStyles(); - const text = getAlertText(alert); const classes = useStyles(); return ( @@ -39,8 +37,8 @@ const AlertActionCard = ({ alert, number }: AlertActionCardProps) => { {number}} - title={text?.title} - subheader={text?.subtitle} + title={alert.title} + subheader={alert.subtitle} /> ); diff --git a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCardList.tsx b/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCardList.tsx index 4ddc26e040..1ea1fb6a96 100644 --- a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCardList.tsx +++ b/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCardList.tsx @@ -25,7 +25,7 @@ type AlertActionCardList = { const AlertActionCardList: FC = ({ alerts }) => ( {alerts.map((alert, index) => ( - + {index < alerts.length - 1 && } diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx index 59dac3a758..e0aa0f02d9 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx @@ -19,7 +19,6 @@ import { Grid } from '@material-ui/core'; import AlertInsightsSection from './AlertInsightsSection'; import AlertInsightsHeader from './AlertInsightsHeader'; import { Alert } from '../../types'; -import { renderAlert } from '../../utils/alerts'; const title = "Your team's action items"; const subtitle = @@ -37,11 +36,7 @@ const AlertInsights = ({ alerts }: AlertInsightsProps) => ( {alerts.map((alert, index) => ( - + ))} diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx index c00d924cf2..d2305e390d 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx @@ -16,45 +16,28 @@ import React from 'react'; import { Box, Button } from '@material-ui/core'; import AlertInsightsSectionHeader from './AlertInsightsSectionHeader'; -import { - getAlertButtonText, - getAlertText, - getAlertUrl, -} from '../../utils/alerts'; -import { Alert, Currency } from '../../types'; -import { useCurrency } from '../../hooks'; +import { Alert } from '../../types'; type AlertInsightsSectionProps = { alert: Alert; number: number; - render: (alert: Alert, currency: Currency) => JSX.Element; }; -const AlertInsightsSection = ({ - alert, - number, - render, -}: AlertInsightsSectionProps) => { - const [currency] = useCurrency(); - const text = getAlertText(alert); - const url = getAlertUrl(alert); - const buttonText = getAlertButtonText(alert); - +const AlertInsightsSection = ({ alert, number }: AlertInsightsSectionProps) => { return ( - {/* */} - {render(alert, currency)} + {alert.element} ); }; diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSectionHeader.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSectionHeader.tsx index 5d5f94b9be..a561735d58 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSectionHeader.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSectionHeader.tsx @@ -15,26 +15,22 @@ */ import React from 'react'; -import { Avatar, Box, Typography, Grid } from '@material-ui/core'; -import { Alert } from '../../types'; -import { getAlertNavigation } from '../../utils/alerts'; +import { Avatar, Box, Grid, Typography } from '@material-ui/core'; import { useAlertInsightsSectionStyles as useStyles } from '../../utils/styles'; import { useScroll } from '../../hooks'; type AlertInsightsSectionHeaderProps = { - alert: Alert; number: number; title: string; subtitle: string; }; const AlertInsightsSectionHeader = ({ - alert, number, title, subtitle, }: AlertInsightsSectionHeaderProps) => { - const { ScrollAnchor } = useScroll(getAlertNavigation(alert, number)); + const { ScrollAnchor } = useScroll(`alert-${number}`); const classes = useStyles(); return ( diff --git a/plugins/cost-insights/src/components/AlertInstructionsLayout/AlertInstructionsLayout.tsx b/plugins/cost-insights/src/components/AlertInstructionsLayout/AlertInstructionsLayout.tsx index 0a422c335a..e7ecc8894b 100644 --- a/plugins/cost-insights/src/components/AlertInstructionsLayout/AlertInstructionsLayout.tsx +++ b/plugins/cost-insights/src/components/AlertInstructionsLayout/AlertInstructionsLayout.tsx @@ -19,6 +19,7 @@ import { Box, Button, Container, makeStyles } from '@material-ui/core'; import ChevronLeftIcon from '@material-ui/icons/ChevronLeft'; import { Header, Page, pageTheme } from '@backstage/core'; import { CostInsightsThemeProvider } from '../CostInsightsPage/CostInsightsThemeProvider'; +import { ConfigProvider, CurrencyProvider } from '../../hooks'; const useStyles = makeStyles(theme => ({ root: { @@ -39,21 +40,29 @@ const AlertInstructionsLayout = ({ const classes = useStyles(); return ( - -
- - - - - {children} - - + + + +
+ + + + + {children} + + + + ); }; diff --git a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx index c426eff8bc..8922ac31e0 100644 --- a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx +++ b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx @@ -15,7 +15,7 @@ */ import React, { useCallback, useEffect, useState } from 'react'; -import { Box, Container, Divider, Grid } from '@material-ui/core'; +import { Box, Container, Divider, Grid, Typography } from '@material-ui/core'; import { Progress, useApi, featureFlagsApiRef } from '@backstage/core'; import { default as MaterialAlert } from '@material-ui/lab/Alert'; import { costInsightsApiRef } from '../../api'; @@ -41,6 +41,7 @@ import { } from '../../hooks'; import { Alert, Cost, intervalsOf, Maybe, Project } from '../../types'; import { mapLoadingToProps } from './selector'; +import ProjectSelect from '../ProjectSelect'; const CostInsightsPage = () => { const flags = useApi(featureFlagsApiRef).getFlags(); @@ -55,7 +56,7 @@ const CostInsightsPage = () => { const [alerts, setAlerts] = useState>(null); const [error, setError] = useState>(null); - const { pageFilters } = useFilters(p => p); + const { pageFilters, setPageFilters } = useFilters(p => p); const { loadingActions, loadingGroups, @@ -63,6 +64,7 @@ const CostInsightsPage = () => { dispatchInitial, dispatchInsights, dispatchNone, + dispatchReset, } = useLoading(mapLoadingToProps); /* eslint-disable react-hooks/exhaustive-deps */ @@ -75,8 +77,15 @@ const CostInsightsPage = () => { const dispatchLoadingInitial = useCallback(dispatchInitial, []); const dispatchLoadingInsights = useCallback(dispatchInsights, []); const dispatchLoadingNone = useCallback(dispatchNone, []); + const dispatchLoadingReset = useCallback(dispatchReset, []); /* eslint-enable react-hooks/exhaustive-deps */ + const setProject = (project: Maybe) => + setPageFilters({ + ...pageFilters, + project: project === 'all' ? null : project, + }); + useEffect(() => { async function getInsights() { setError(null); @@ -165,6 +174,41 @@ const CostInsightsPage = () => { ); } + const onProjectSelect = (project: Maybe) => { + setProject(project); + dispatchLoadingReset(loadingActions); + }; + + const CostOverviewBanner = () => ( + + + Cost Overview + + + {!!flags.get('cost-insights-currencies') && ( + + + + )} + + + + ); + return ( @@ -180,15 +224,6 @@ const CostInsightsPage = () => { justifyContent="flex-end" mb={2} > - {!!flags.get('cost-insights-currencies') && ( - - - - )} @@ -212,6 +247,9 @@ const CostInsightsPage = () => { )} + + + {!!dailyCost.aggregation.length && ( diff --git a/plugins/cost-insights/src/components/CostInsightsPage/selector.tsx b/plugins/cost-insights/src/components/CostInsightsPage/selector.tsx index 51db47c082..8a9cf021c9 100644 --- a/plugins/cost-insights/src/components/CostInsightsPage/selector.tsx +++ b/plugins/cost-insights/src/components/CostInsightsPage/selector.tsx @@ -14,7 +14,11 @@ * limitations under the License. */ import { MapLoadingToProps } from '../../hooks'; -import { getResetState, DefaultLoadingAction } from '../../types'; +import { + getResetState, + DefaultLoadingAction, + getResetStateWithoutInitial, +} from '../../types'; type CostInsightsPageLoadingProps = { loadingActions: Array; @@ -23,6 +27,7 @@ type CostInsightsPageLoadingProps = { dispatchInitial: (isLoading: boolean) => void; dispatchInsights: (isLoading: boolean) => void; dispatchNone: (loadingActions: string[]) => void; + dispatchReset: (loadingActions: string[]) => void; }; export const mapLoadingToProps: MapLoadingToProps = ({ @@ -39,4 +44,6 @@ export const mapLoadingToProps: MapLoadingToProps dispatch({ [DefaultLoadingAction.CostInsightsPage]: isLoading }), dispatchNone: (loadingActions: string[]) => dispatch(getResetState(loadingActions)), + dispatchReset: (loadingActions: string[]) => + dispatch(getResetStateWithoutInitial(loadingActions)), }); diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx index 8bcdc5b323..b8d1be5d01 100644 --- a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx @@ -19,10 +19,8 @@ import { Box, Card, CardContent, Divider } from '@material-ui/core'; import CostOverviewChart from '../CostOverviewChart'; import CostOverviewChartLegend from '../CostOverviewChartLegend'; import CostOverviewHeader from './CostOverviewHeader'; -import CostOverviewFooter from './CostOverviewFooter'; import MetricSelect from '../MetricSelect'; import PeriodSelect from '../PeriodSelect'; -import ProjectSelect from '../ProjectSelect'; import { useScroll, useFilters, useConfig } from '../../hooks'; import { mapFiltersToProps } from './selector'; import { DefaultNavigation } from '../../utils/navigation'; @@ -45,7 +43,6 @@ const CostOverviewCard = ({ change, aggregation, trendline, - projects, }: CostOverviewCardProps) => { const { metrics } = useConfig(); const { ScrollAnchor } = useScroll(DefaultNavigation.CostOverviewCard); @@ -73,18 +70,13 @@ const CostOverviewCard = ({ trendline={trendline} /> - - + - + ); diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx index 1d24139c64..c9790a5bfa 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx @@ -39,7 +39,7 @@ const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => { const [resource, setResource] = useState>(null); const [error, setError] = useState>(null); - const { group, product: productFilter, setProduct } = useFilters( + const { group, product: productFilter, setProduct, project } = useFilters( mapFiltersToProps(product.kind), ); const { loadingProduct, dispatchLoading } = useLoading( @@ -68,6 +68,7 @@ const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => { product.kind, group!, productFilter!.duration, + project, ); setResource(p); } catch (e) { @@ -87,6 +88,7 @@ const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => { productFilter, group, product.kind, + project, ]); const onPeriodSelect = (duration: Duration) => { diff --git a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.test.tsx b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.test.tsx index 5c3856cc9e..b914b3885d 100644 --- a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.test.tsx +++ b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.test.tsx @@ -17,7 +17,7 @@ import React from 'react'; import { renderInTestApp } from '@backstage/test-utils'; import ProjectGrowthAlertCard from './ProjectGrowthAlertCard'; -import { createMockProjectGrowthAlert } from '../../utils/mockData'; +import { createMockProjectGrowthData } from '../../utils/mockData'; import { MockCurrencyProvider, MockConfigProvider } from '../../utils/tests'; import { AlertCost, defaultCurrencies, findAlways } from '../../types'; @@ -29,8 +29,8 @@ const MockAlertCosts: AlertCost[] = [ { id: 'test-id-2', aggregation: [235, 400] }, ]; -const MockProjectGrowthAlert = createMockProjectGrowthAlert(alert => ({ - ...alert, +const MockProjectGrowthAlert = createMockProjectGrowthData(data => ({ + ...data, project: MockProject, products: MockAlertCosts, })); diff --git a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx index c27b201202..8d1d1d29b1 100644 --- a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx +++ b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx @@ -19,11 +19,11 @@ import { Box } from '@material-ui/core'; import { InfoCard } from '@backstage/core'; import ResourceGrowthBarChart from '../ResourceGrowthBarChart'; import ResourceGrowthBarChartLegend from '../ResourceGrowthBarChartLegend'; -import { Duration, ProjectGrowthAlert } from '../../types'; +import { Duration, ProjectGrowthData } from '../../types'; import { pluralOf } from '../../utils/grammar'; type ProjectGrowthAlertProps = { - alert: ProjectGrowthAlert; + alert: ProjectGrowthData; }; const ProjectGrowthAlertCard = ({ alert }: ProjectGrowthAlertProps) => { diff --git a/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx b/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx index f8f4a90a9e..cde989c9dd 100644 --- a/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx +++ b/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx @@ -18,20 +18,19 @@ import React from 'react'; import { Box, Typography } from '@material-ui/core'; import { InfoCard } from '@backstage/core'; import AlertInstructionsLayout from '../AlertInstructionsLayout'; -import ProjectGrowthAlertCard from '../ProjectGrowthAlertCard'; import { - AlertType, + Alert, Duration, Entity, Product, ProjectGrowthAlert, + ProjectGrowthData, } from '../../types'; import ResourceGrowthBarChartLegend from '../ResourceGrowthBarChartLegend'; import ResourceGrowthBarChart from '../ResourceGrowthBarChart'; const ProjectGrowthInstructionsPage = () => { - const projectGrowthAlert: ProjectGrowthAlert = { - id: AlertType.ProjectGrowth, + const alertData: ProjectGrowthData = { project: 'example-project', periodStart: 'Q1 2020', periodEnd: 'Q2 2020', @@ -55,6 +54,7 @@ const ProjectGrowthInstructionsPage = () => { }, ], }; + const projectGrowthAlert: Alert = new ProjectGrowthAlert(alertData); const product: Product = { kind: 'ComputeEngine', @@ -135,7 +135,7 @@ const ProjectGrowthInstructionsPage = () => { comparison of cloud products over the examined time period: - + {projectGrowthAlert.element} This allows you to quickly see which cloud products contributed to the diff --git a/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.tsx b/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.tsx index cbcd8ede4e..934ad99f63 100644 --- a/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.tsx +++ b/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.tsx @@ -48,7 +48,7 @@ const ProjectSelect = ({ project, projects, onSelect }: ProjectSelectProps) => {