diff --git a/.husky/pre-commit b/.husky/pre-commit index 6a9fe43..3419025 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1 @@ -yarn lint-staged && yarn tsc +yarn lint:type-deps && yarn lint-staged && yarn tsc && yarn prettier:check diff --git a/README.md b/README.md index cb44744..e4712c3 100644 --- a/README.md +++ b/README.md @@ -18,269 +18,13 @@ ## Getting started -### Default Settings for Frontend +To start using InfraWallet, see the [Getting Started documentation](./docs/getting-started.md). -Site admins can configure the default view for InfraWallet, including the default group by dimension, and the default -query period. Add the following configurations to your `app-config.yaml` file if the default view needs to be changed. +From `v0.1.8`, InfraWallet also supports displaying business metrics as line charts on the same cost graph (beta feature for now). Currently the supported metric providers include Datadog and GrafanaCloud. See the steps on [this page](./docs/integrate-business-metrics.md) if you would like to try it out. -```yaml -# note that infraWallet exists at the root level, it is not the same one for backend configurations -infraWallet: - settings: - defaultGroupBy: none # none by default, or provider, category, service, tag: - defaultShowLastXMonths: 3 # 3 by default, or other numbers, we recommend it less than 12 -``` +## Contributing to InfraWallet -### Define Cloud Accounts in app-config.yaml - -The configuration schema of InfraWallet is defined in the [plugins/infrawallet-backend/config.d.ts](plugins/infrawallet-backend/config.d.ts) file. Users need to configure their cloud accounts in the `app-config.yaml` in the root folder. - -#### AWS - -For AWS, InfraWallet relies on an IAM role to fetch cost and usage data using AWS Cost Explorer APIs. Thus before adding the configurations, AWS IAM role and policy need to be set up. If you have multiple AWS accounts, you should create a role in each account and configure trust relationships for it. The role to be assumed in an AWS account needs the following permission: - -```json -{ - "Statement": [ - { - "Action": "ce:GetCostAndUsage", - "Effect": "Allow", - "Resource": "*", - "Sid": "" - } - ], - "Version": "2012-10-17" -} -``` - -After getting the IAM-related resources ready, put the following configuration into `app-config.yaml`: - -```yaml -backend: - infraWallet: - integrations: - aws: - - name: - accountId: '<12-digit_account_ID>' # quoted as a string - assumedRoleName: - accessKeyId: # optional, only needed when an IAM user is used to assume the role - accessKeySecret: # optional, only needed when an IAM user is used to assume the role -``` - -The AWS client in InfraWallet is implemented using AWS SDK for JavaScript. If `accessKeyId` and `accessKeySecret` are defined in the configuration, it uses the configured IAM user to assume the role. Otherwise, the client follows the credential provider chain documented [here](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-credentials-node.html#credchain). - -#### Azure - -In order to manage Azure costs, an application needs to be registered on Azure. InfraWallet is only tested with subscription-level cost data. After creating the application, users need to go to the `Subscriptions` page, choose the target subscription and then visit the `Access control (IAM)` page. Assign the `Cost Management Reader` role to the created application. Create a new client secret for the application, and add the following configurations in `app-config.yaml`: - -```yaml -backend: - infraWallet: - integrations: - azure: - - name: - subscriptionId: - tenantId: - clientId: - clientSecret: -``` - -#### GCP - -InfraWallet relies on GCP Big Query to fetch cost data. This means that the billing data needs to be exported to a big query dataset, and a service account needs to be created for InfraWallet. The steps of exporting billing data to Big Query can be found [here](https://cloud.google.com/billing/docs/how-to/export-data-bigquery). Then, visit Google Cloud Console and navigate to the `IAM & Admin` section in the billing account. Click `Service Accounts`, and create a new service account. The service account needs to have `BigQuery Data Viewer` and `BigQuery Job User` roles. On the `Service Accounts` page, click the three dots (menu) in the `Actions` column for the newly created service account and select `Manage keys`. There click `Add key` -> `Create new key`, and use `JSON` as the format. Download the JSON key file and keep it safe. - -After setting up the resources above, add the following configurations in `app-config.yaml`: - -```yaml -backend: - infraWallet: - integrations: - gcp: - - name: - keyFilePath: # if you run it in a k8s pod, you may need to create a secret and mount it to the pod - projectId: - datasetId: - tableId: -``` - -### Adjust Category Mappings if Needed - -The category mappings are stored in the plugin's database. If there is no mapping found in the DB when initializing the plugin, the default mappings will be used. The default mappings can be found in the [plugins/infrawallet-backend/seeds/init.js](plugins/infrawallet-backend/seeds/init.js) file. You can adjust this seed file to fit your needs, or update the database directly later on. - -### Install the Plugin - -#### If Backstage New Backend System is enabled - -1. add InfraWallet frontend - -``` -# From your Backstage root directory -yarn --cwd packages/app add @electrolux-oss/plugin-infrawallet -``` - -modify `packages/app/src/App.tsx` and add the following code - -```ts -... -import { InfraWalletPage } from '@electrolux-oss/plugin-infrawallet'; -... - - ... - } /> - -... -``` - -2. add InfraWallet backend - -``` -# From your Backstage root directory -yarn --cwd packages/backend add @electrolux-oss/plugin-infrawallet-backend -``` - -modify `packages/backend/src/index.ts` and add the following code before `backend.start()`; - -```typescript -... -// InfraWallet backend -backend.add(import('@electrolux-oss/plugin-infrawallet-backend')); -... -backend.start(); -``` - -3. add cloud account credentials to `app-config.yaml` - Here is an example of the configuration for AWS and Azure accounts: - -```yaml -backend: - infraWallet: - integrations: - azure: - - name: - subscriptionId: ... - tenantId: ... - clientId: ... - clientSecret: ... - - name: - subscriptionId: ... - tenantId: ... - clientId: ... - clientSecret: ... - aws: - - name: - accountId: '<12-digit_account_ID_as_string>' - assumedRoleName: ... - accessKeyId: ... - accessKeySecret: ... - - name: - accountId: '<12-digit_account_ID_as_string>' - assumedRoleName: ... - accessKeyId: ... - accessKeySecret: ... -``` - -4. add InfraWallet to the sidebar (optional) - -modify `packages/app/src/components/Root/Root.tsx` and add the following code - -```ts -... -import { InfraWalletIcon } from '@electrolux-oss/plugin-infrawallet'; -... - - ... - }> - - - ... - -``` - -#### If the legacy Backstage backend system is used - -The 2nd step above (adding the backend) is different and it should be like the following. - -``` -# From your Backstage root directory -yarn --cwd packages/backend add @electrolux-oss/plugin-infrawallet-backend -``` - -create a file `infrawallet.ts` in folder `packages/backend/src/plugins/` with the following content. - -```ts -import { createRouter } from '@electrolux-oss/plugin-infrawallet-backend'; -import { Router } from 'express'; -import { PluginEnvironment } from '../types'; - -export default async function createPlugin(env: PluginEnvironment): Promise { - return await createRouter({ - logger: env.logger, - config: env.config, - cache: env.cache.getClient(), - database: env.database, - }); -} -``` - -then modify `packages/backend/src/index.ts` - -```ts -... -import infraWallet from './plugins/infrawallet'; -... -async function main() { - ... - const infraWalletEnv = useHotMemoize(module, () => createEnv('infrawallet')); - ... - apiRouter.use('/infrawallet', authMiddleware, await infraWallet(infraWalletEnv)); - ... -} -``` - -## Local Development - -First of all, make sure you are using either Node 18 or Node 20 for this project. Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn install && yarn dev` in the root directory, and then navigating to [/infrawallet](http://localhost:3000/infrawallet). - -You can also serve the plugin in isolation by running `yarn start` in the plugin directory. -This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads. -It is only meant for local development, and the setup for it can be found inside a plugin's `dev` directory (e.g., [plugins/infrawallet/dev](./plugins/infrawallet/dev)). - -## How to Support a New Cloud Vendor? - -In InfraWallet, all the cost data fetched from different cloud providers are transformed into a generic format: - -```typescript -export type Report = { - id: string; // the unique ID of a cloud account which is defined in the app-config.yaml file - [dimension: string]: string | { [period: string]: number } | undefined; // other dimensions such as category, service, a tag, etc. - reports?: { - [period: string]: number; // the reports which are in the following format ["period": cost], such as ["2024-01": 12.23, "2024-02": 23.21] - }; -}; -``` - -For example, here is a report returned from InfraWallet backend: - -```json -{ - "id": "my-aws-dev-account", - "provider": "aws", - "category": "Infrastructure", - "service": "EC2", - "reports": { - "2024-01": 12.23, - "2024-02": 23.21 - } -} -``` - -The aggregation is done by the frontend after getting all the needed cost reports. This means that as long as the backend returns more cost reports in the same format, InfraWallet can always aggregate and visualize the costs. - -When adding a new cloud vendor, you need to implement a client based on the abstract class [InfraWalletClient](plugins/infrawallet-backend/src/service/InfraWalletClient.ts). Check [AwsClient.ts](plugins/infrawallet-backend/src/service/AwsClient.ts) and [AzureClient.ts](plugins/infrawallet-backend/src/service/AzureClient.ts) as examples. +There are different ways to contribute to InfraWallet, see examples [here](https://medium.com/@infrawalletbox/contribute-to-infrawallet-5-ways-to-get-started-today-42051b8ff8c6). To join the coding, you can start from this [documentation](./docs/contributing.md). ## Roadmap diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..54beaf6 --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,40 @@ +## Local Development + +First of all, make sure you are using either Node 18 or Node 20 for this project. Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn install && yarn dev` in the root directory, and then navigating to [/infrawallet](http://localhost:3000/infrawallet). + +You can also serve the plugin in isolation by running `yarn start` in the plugin directory. +This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads. +It is only meant for local development, and the setup for it can be found inside a plugin's `dev` directory (e.g., [plugins/infrawallet/dev](../plugins/infrawallet/dev)). + +## How to Support a New Cloud Vendor? + +In InfraWallet, all the cost data fetched from different cloud providers are transformed into a generic format: + +```typescript +export type Report = { + id: string; // the unique ID of a cloud account which is defined in the app-config.yaml file + [dimension: string]: string | { [period: string]: number } | undefined; // other dimensions such as category, service, a tag, etc. + reports?: { + [period: string]: number; // the reports which are in the following format ["period": cost], such as ["2024-01": 12.23, "2024-02": 23.21] + }; +}; +``` + +For example, here is a report returned from InfraWallet backend: + +```json +{ + "id": "my-aws-dev-account", + "provider": "aws", + "category": "Infrastructure", + "service": "EC2", + "reports": { + "2024-01": 12.23, + "2024-02": 23.21 + } +} +``` + +The aggregation is done by the frontend after getting all the needed cost reports. This means that as long as the backend returns more cost reports in the same format, InfraWallet can always aggregate and visualize the costs. + +When adding a new cloud vendor, you need to implement a client based on the abstract class [InfraWalletClient](../plugins/infrawallet-backend/src/service/InfraWalletClient.ts). Check [AwsClient.ts](../plugins/infrawallet-backend/src/service/AwsClient.ts) and [AzureClient.ts](../plugins/infrawallet-backend/src/service/AzureClient.ts) as examples. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..230beb1 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,222 @@ +## Default Settings for Frontend + +Site admins can configure the default view for InfraWallet, including the default group by dimension, and the default +query period. Add the following configurations to your `app-config.yaml` file if the default view needs to be changed. + +```yaml +# note that infraWallet exists at the root level, it is not the same one for backend configurations +infraWallet: + settings: + defaultGroupBy: none # none by default, or provider, category, service, tag: + defaultShowLastXMonths: 3 # 3 by default, or other numbers, we recommend it less than 12 +``` + +## Define Cloud Accounts in app-config.yaml + +The configuration schema of InfraWallet is defined in the [plugins/infrawallet-backend/config.d.ts](../plugins/infrawallet-backend/config.d.ts) file. Users need to configure their cloud accounts in the `app-config.yaml` in the root folder. + +### AWS + +For AWS, InfraWallet relies on an IAM role to fetch cost and usage data using AWS Cost Explorer APIs. Thus before adding the configurations, AWS IAM role and policy need to be set up. If you have multiple AWS accounts, you should create a role in each account and configure trust relationships for it. The role to be assumed in an AWS account needs the following permission: + +```json +{ + "Statement": [ + { + "Action": "ce:GetCostAndUsage", + "Effect": "Allow", + "Resource": "*", + "Sid": "" + } + ], + "Version": "2012-10-17" +} +``` + +After getting the IAM-related resources ready, put the following configuration into `app-config.yaml`: + +```yaml +backend: + infraWallet: + integrations: + aws: + - name: + accountId: '<12-digit_account_ID>' # quoted as a string + assumedRoleName: + accessKeyId: # optional, only needed when an IAM user is used to assume the role + accessKeySecret: # optional, only needed when an IAM user is used to assume the role +``` + +The AWS client in InfraWallet is implemented using AWS SDK for JavaScript. If `accessKeyId` and `accessKeySecret` are defined in the configuration, it uses the configured IAM user to assume the role. Otherwise, the client follows the credential provider chain documented [here](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-credentials-node.html#credchain). + +### Azure + +In order to manage Azure costs, an application needs to be registered on Azure. InfraWallet is only tested with subscription-level cost data. After creating the application, users need to go to the `Subscriptions` page, choose the target subscription and then visit the `Access control (IAM)` page. Assign the `Cost Management Reader` role to the created application. Create a new client secret for the application, and add the following configurations in `app-config.yaml`: + +```yaml +backend: + infraWallet: + integrations: + azure: + - name: + subscriptionId: + tenantId: + clientId: + clientSecret: +``` + +### GCP + +InfraWallet relies on GCP Big Query to fetch cost data. This means that the billing data needs to be exported to a big query dataset, and a service account needs to be created for InfraWallet. The steps of exporting billing data to Big Query can be found [here](https://cloud.google.com/billing/docs/how-to/export-data-bigquery). Then, visit Google Cloud Console and navigate to the `IAM & Admin` section in the billing account. Click `Service Accounts`, and create a new service account. The service account needs to have `BigQuery Data Viewer` and `BigQuery Job User` roles. On the `Service Accounts` page, click the three dots (menu) in the `Actions` column for the newly created service account and select `Manage keys`. There click `Add key` -> `Create new key`, and use `JSON` as the format. Download the JSON key file and keep it safe. + +After setting up the resources above, add the following configurations in `app-config.yaml`: + +```yaml +backend: + infraWallet: + integrations: + gcp: + - name: + keyFilePath: # if you run it in a k8s pod, you may need to create a secret and mount it to the pod + projectId: + datasetId: + tableId: +``` + +## Adjust Category Mappings if Needed + +The category mappings are stored in the plugin's database. If there is no mapping found in the DB when initializing the plugin, the default mappings will be used. The default mappings can be found in the [plugins/infrawallet-backend/seeds/init.js](../plugins/infrawallet-backend/seeds/init.js) file. You can adjust this seed file to fit your needs, or update the database directly later on. + +## Install the Plugin + +### If Backstage New Backend System is enabled + +1. add InfraWallet frontend + +``` +# From your Backstage root directory +yarn --cwd packages/app add @electrolux-oss/plugin-infrawallet +``` + +modify `packages/app/src/App.tsx` and add the following code + +```ts +... +import { InfraWalletPage } from '@electrolux-oss/plugin-infrawallet'; +... + + ... + } /> + +... +``` + +2. add InfraWallet backend + +``` +# From your Backstage root directory +yarn --cwd packages/backend add @electrolux-oss/plugin-infrawallet-backend +``` + +modify `packages/backend/src/index.ts` and add the following code before `backend.start()`; + +```typescript +... +// InfraWallet backend +backend.add(import('@electrolux-oss/plugin-infrawallet-backend')); +... +backend.start(); +``` + +3. add cloud account credentials to `app-config.yaml` + Here is an example of the configuration for AWS and Azure accounts: + +```yaml +backend: + infraWallet: + integrations: + azure: + - name: + subscriptionId: ... + tenantId: ... + clientId: ... + clientSecret: ... + - name: + subscriptionId: ... + tenantId: ... + clientId: ... + clientSecret: ... + aws: + - name: + accountId: '<12-digit_account_ID_as_string>' + assumedRoleName: ... + accessKeyId: ... + accessKeySecret: ... + - name: + accountId: '<12-digit_account_ID_as_string>' + assumedRoleName: ... + accessKeyId: ... + accessKeySecret: ... +``` + +4. add InfraWallet to the sidebar (optional) + +modify `packages/app/src/components/Root/Root.tsx` and add the following code + +```ts +... +import { InfraWalletIcon } from '@electrolux-oss/plugin-infrawallet'; +... + + ... + }> + + + ... + +``` + +### If the legacy Backstage backend system is used + +The 2nd step above (adding the backend) is different and it should be like the following. + +``` +# From your Backstage root directory +yarn --cwd packages/backend add @electrolux-oss/plugin-infrawallet-backend +``` + +create a file `infrawallet.ts` in folder `packages/backend/src/plugins/` with the following content. + +```ts +import { createRouter } from '@electrolux-oss/plugin-infrawallet-backend'; +import { Router } from 'express'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin(env: PluginEnvironment): Promise { + return await createRouter({ + logger: env.logger, + config: env.config, + cache: env.cache.getClient(), + database: env.database, + }); +} +``` + +then modify `packages/backend/src/index.ts` + +```ts +... +import infraWallet from './plugins/infrawallet'; +... +async function main() { + ... + const infraWalletEnv = useHotMemoize(module, () => createEnv('infrawallet')); + ... + apiRouter.use('/infrawallet', authMiddleware, await infraWallet(infraWalletEnv)); + ... +} +``` diff --git a/docs/images/business_metrics_example.png b/docs/images/business_metrics_example.png new file mode 100644 index 0000000..ba27c11 Binary files /dev/null and b/docs/images/business_metrics_example.png differ diff --git a/docs/images/business_metrics_setting.png b/docs/images/business_metrics_setting.png new file mode 100644 index 0000000..90038fb Binary files /dev/null and b/docs/images/business_metrics_setting.png differ diff --git a/docs/integrate-business-metrics.md b/docs/integrate-business-metrics.md new file mode 100644 index 0000000..3b1186c --- /dev/null +++ b/docs/integrate-business-metrics.md @@ -0,0 +1,68 @@ +## Display Business Metrics Together With Costs Data + +To better understand the trend of your cloud costs, it can be very helpful to check business metrics at the same time. +For example, an increase of some cloud costs may be caused by the larger number of active users, which justify the cost +increase. + +As a beta feature, InfraWallet supports fetching metrics from different providers (Datadog and GrafanaCloud are +supported for now) and then show such metrics as line charts on the same cost graph. Here is an example of how it looks: + +![business-metrics](./images/business_metrics_example.png) + +### Configure Metric Providers + +#### Datadog + +InfraWallet uses Datadog's [Query timeseries points](https://docs.datadoghq.com/api/latest/metrics/#query-timeseries-points) API to fetch metric points. Before jumping into the `app-config.yaml`, you need to create an API key and an application key for InfraWallet, see the official documentations [here](https://docs.datadoghq.com/account_management/api-app-keys/). If the application key is scoped, it needs to have the `timeseries_query` scope. + +The configuration for a Datadog metric provider looks like the following: + +```yaml +backend: + infraWallet: + metricProviders: + datadog: + # it is possible to have multiple datadog metric providers + - name: + apiKey: + applicationKey: + ddSite: https://api.datadoghq.eu # see alternatives in https://docs.datadoghq.com/getting_started/site/#access-the-datadog-site +``` + +#### GrafanaCloud + +InfraWallet uses GrafanaCloud's [Query a data source](https://grafana.com/docs/grafana-cloud/developer-resources/api-reference/http-api/data_source/#query-a-data-source) API to fetch metric points. In order to connect your GrafanaCloud organization, you need to create a service account and a token. The documentation can be found [here](https://grafana.com/docs/grafana/latest/administration/service-accounts/#create-a-service-account-in-grafana). After creating the service account token, add the following part into the configuration: + +```yaml +backend: + infraWallet: + metricProviders: + grafanacloud: + # it is possible to have multiple GrafanaCloud metric providers + - name: + url: # e.g., https://foo.grafana.net + datasourceUid: + token: +``` + +### Configure Metrics + +Visit `/infrawallet/default/settings` page to configure metrics. + +![business-metric-settings](./images/business_metrics_setting.png) + +#### Notes + +For Datadog, because InfraWallet only has daily or monthly granularities for costs, it is recommended to add a [rollup](https://docs.datadoghq.com/dashboards/functions/rollup/) +function to each query. Regarding the aggregation interval, `IW_INTERVAL` can be used as a placeholder and later on, +InfraWallet replaces it with the actual value (`86400` or `2592000`) based on the granularity. For example: + +``` +avg:system.cpu.idle{*}.rollup(avg, IW_INTERVAL) +``` + +Similar to Datadog, it is recommended to apply an [aggregation_over_time()](https://prometheus.io/docs/prometheus/latest/querying/functions/#aggregation_over_time) for each query in Grafana Cloud. For instance: + +``` +max(max_over_time(active_user[IW_INTERVAL])) +``` diff --git a/plugins/infrawallet-backend/config.d.ts b/plugins/infrawallet-backend/config.d.ts index 93eb8c5..0a825db 100644 --- a/plugins/infrawallet-backend/config.d.ts +++ b/plugins/infrawallet-backend/config.d.ts @@ -42,6 +42,33 @@ export interface Config { }, ]; }; + metricProviders?: { + datadog?: [ + { + name: string; + /** + * @visibility secret + */ + apiKey: string; + /** + * @visibility secret + */ + applicationKey: string; + ddSite: string; + }, + ]; + grafanaCloud?: [ + { + name: string; + url: string; + datasourceUid: string; + /** + * @visibility secret + */ + token: string; + }, + ]; + }; }; }; } diff --git a/plugins/infrawallet-backend/migrations/20240807123438_wallet-and-metric-configurations.js b/plugins/infrawallet-backend/migrations/20240807123438_wallet-and-metric-configurations.js new file mode 100644 index 0000000..f9dd667 --- /dev/null +++ b/plugins/infrawallet-backend/migrations/20240807123438_wallet-and-metric-configurations.js @@ -0,0 +1,49 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async function up(knex) { + await knex.schema + // + // wallets + // + .createTable('wallets', table => { + table.comment('Wallets defined by users'); + table.uuid('id').defaultTo(knex.fn.uuid()).primary().notNullable().comment('Auto-generated ID of a wallet'); + table.string('name').notNullable().comment('The display name for a wallet'); + table.string('currency').notNullable().comment('The currency for displaying the costs'); + table.string('description').comment('The description of a wallet'); + }); + + await knex.schema + // + // business metric configurations for wallets + // + .createTable('business_metrics', table => { + table.comment('Business metric configurations for wallets'); + table + .uuid('id') + .defaultTo(knex.fn.uuid()) + .primary() + .notNullable() + .comment('Auto-generated ID of a metric configuration'); + table.uuid('wallet_id').notNullable().comment('The ID of the wallet that has this metric configuration'); + table.string('metric_provider').notNullable().comment('Provider type, either datadog or grafanacloud'); + table.string('config_name').notNullable().comment('Name of a specific metric provider config'); + table.string('metric_name').notNullable().comment('Display name of a metric'); + table.text('description').comment('Description of a metric'); + table + .text('query') + .notNullable() + .comment('Query string (`IW_INTERVAL` will be replaced with the interval value based on the granularity)'); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async function down(knex) { + await knex.schema.dropTableIfExists('wallets'); + await knex.schema.dropTableIfExists('business_metrics'); +}; diff --git a/plugins/infrawallet-backend/package.json b/plugins/infrawallet-backend/package.json index 9ace7b4..bf31b62 100644 --- a/plugins/infrawallet-backend/package.json +++ b/plugins/infrawallet-backend/package.json @@ -44,6 +44,7 @@ "@backstage/backend-plugin-api": "^0.6.18", "@backstage/config": "^1.2.0", "@backstage/types": "^1.1.1", + "@datadog/datadog-api-client": "^1.26.0", "@google-cloud/bigquery": "7.7.1", "@types/express": "*", "express": "^4.17.1", diff --git a/plugins/infrawallet-backend/seeds/default_wallet.js b/plugins/infrawallet-backend/seeds/default_wallet.js new file mode 100644 index 0000000..4900dbc --- /dev/null +++ b/plugins/infrawallet-backend/seeds/default_wallet.js @@ -0,0 +1,12 @@ +exports.seed = async knex => { + await knex('wallets') + .count('id as c') + .then(async result => { + if (result[0].c === 0 || result[0].c === '0') { + // only insert a record for default wallet when the table is empty + await knex('wallets').insert([ + { name: 'default', currency: 'usd', description: 'The auto-created default wallet.' }, + ]); + } + }); +}; diff --git a/plugins/infrawallet-backend/src/controllers/MetricSettingController.ts b/plugins/infrawallet-backend/src/controllers/MetricSettingController.ts new file mode 100644 index 0000000..0fc88bd --- /dev/null +++ b/plugins/infrawallet-backend/src/controllers/MetricSettingController.ts @@ -0,0 +1,49 @@ +import { DatabaseService } from '@backstage/backend-plugin-api'; +import { MetricSetting, Wallet } from '../service/types'; + +export async function getWallet(database: DatabaseService, walletName: string): Promise { + const client = await database.getClient(); + const result = await client('wallets').where('name', walletName).first(); + + return result; +} + +export async function getWalletMetricSettings(database: DatabaseService, walletName: string): Promise { + const client = await database.getClient(); + + const metricSettings = await client + .select('business_metrics.*') + .from('business_metrics') + .where('wallets.name', walletName) + .join('wallets', 'business_metrics.wallet_id', '=', 'wallets.id'); + + return metricSettings; +} + +export async function updateOrInsertWalletMetricSetting( + database: DatabaseService, + walletSetting: MetricSetting, +): Promise { + const client = await database.getClient(); + const result: number[] = await client('business_metrics').insert(walletSetting).onConflict('id').merge(); + + if (result[0] > 0) { + return true; + } + + return false; +} + +export async function deleteWalletMetricSetting( + database: DatabaseService, + walletSetting: MetricSetting, +): Promise { + const client = await database.getClient(); + const result: number = await client('business_metrics').where('id', walletSetting.id).del(); + + if (result > 0) { + return true; + } + + return false; +} diff --git a/plugins/infrawallet-backend/src/service/DatadogProvider.ts b/plugins/infrawallet-backend/src/service/DatadogProvider.ts new file mode 100644 index 0000000..03c38c1 --- /dev/null +++ b/plugins/infrawallet-backend/src/service/DatadogProvider.ts @@ -0,0 +1,67 @@ +import { CacheService, DatabaseService, LoggerService } from '@backstage/backend-plugin-api'; +import { Config } from '@backstage/config'; +import { v1 as datadogApiV1, client as datadogClient } from '@datadog/datadog-api-client'; +import moment from 'moment'; +import { MetricProvider } from './MetricProvider'; +import { Metric, MetricQuery } from './types'; + +export class DatadogProvider extends MetricProvider { + static create(config: Config, database: DatabaseService, cache: CacheService, logger: LoggerService) { + return new DatadogProvider('Datadog', config, database, cache, logger); + } + + async initProviderClient(config: Config): Promise { + const apiKey = config.getString('apiKey'); + const applicationKey = config.getString('applicationKey'); + const ddSite = config.getString('ddSite'); + const configuration = datadogClient.createConfiguration({ + baseServer: new datadogClient.BaseServerConfiguration(ddSite, {}), + authMethods: { + apiKeyAuth: apiKey, + appKeyAuth: applicationKey, + }, + }); + const client = new datadogApiV1.MetricsApi(configuration); + return client; + } + + async fetchMetrics(_metricProviderConfig: Config, client: any, query: MetricQuery): Promise { + const params: datadogApiV1.MetricsApiQueryMetricsRequest = { + from: parseInt(query.startTime, 10) / 1000, + to: parseInt(query.endTime, 10) / 1000, + query: query.query?.replaceAll('IW_INTERVAL', query.granularity === 'daily' ? '86400' : '2592000') as string, + }; + return client.queryMetrics(params).then((data: datadogApiV1.MetricsQueryResponse) => { + if (data.status === 'ok') { + return data; + } + throw new Error(data.error); + }); + } + + async transformMetricData(_metricProviderConfig: Config, query: MetricQuery, metricResponse: any): Promise { + const transformedData = []; + + for (const series of metricResponse.series) { + const metricName = query.name as string; + const tagSet = series.tagSet; + + const metric: Metric = { + id: `${metricName} ${tagSet.length === 0 ? '' : tagSet}`, + provider: this.providerName, + name: metricName, + reports: {}, + }; + + for (const point of series.pointlist) { + const period = moment(point[0]).format(query.granularity === 'daily' ? 'YYYY-MM-DD' : 'YYYY-MM'); + const value = point[1]; + metric.reports[period] = value; + } + + transformedData.push(metric); + } + + return transformedData; + } +} diff --git a/plugins/infrawallet-backend/src/service/GrafanaCloudProvider.ts b/plugins/infrawallet-backend/src/service/GrafanaCloudProvider.ts new file mode 100644 index 0000000..8448d7b --- /dev/null +++ b/plugins/infrawallet-backend/src/service/GrafanaCloudProvider.ts @@ -0,0 +1,74 @@ +import { CacheService, DatabaseService, LoggerService } from '@backstage/backend-plugin-api'; +import { Config } from '@backstage/config'; +import moment from 'moment'; +import fetch from 'node-fetch'; +import { MetricProvider } from './MetricProvider'; +import { Metric, MetricQuery } from './types'; + +export class GrafanaCloudProvider extends MetricProvider { + static create(config: Config, database: DatabaseService, cache: CacheService, logger: LoggerService) { + return new GrafanaCloudProvider('GrafanaCloud', config, database, cache, logger); + } + + async initProviderClient(_config: Config): Promise { + // for now we don't need to use Grafana Cloud SDK + return null; + } + + async fetchMetrics(metricProviderConfig: Config, _client: any, query: MetricQuery): Promise { + const url = metricProviderConfig.getString('url'); + const datasourceUid = metricProviderConfig.getString('datasourceUid'); + const token = metricProviderConfig.getString('token'); + + const headers = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }; + const payload = { + queries: [ + { + datasource: { + uid: datasourceUid, + }, + expr: query.query?.replaceAll('IW_INTERVAL', query.granularity === 'daily' ? '1d' : '30d'), + refId: 'A', + }, + ], + from: query.startTime, + to: query.endTime, + }; + + const response = await fetch(`${url}/api/ds/query`, { + method: 'post', + body: JSON.stringify(payload), + headers: headers, + }); + const data = await response.json(); + + return data; + } + + async transformMetricData(_metricProviderConfig: Config, query: MetricQuery, metricResponse: any): Promise { + const transformedData = []; + + const metricName = query.name as string; + const metric: Metric = { + id: metricName, + provider: this.providerName, + name: metricName, + reports: {}, + }; + + // TODO: iterate all the series + const periods = metricResponse.results.A.frames[0].data.values[0]; + const values = metricResponse.results.A.frames[0].data.values[1]; + for (let i = 0; i < periods.length; i++) { + const period = moment(periods[i]).format(query.granularity === 'daily' ? 'YYYY-MM-DD' : 'YYYY-MM'); + const value = values[i]; + metric.reports[period] = value; + } + + transformedData.push(metric); + return transformedData; + } +} diff --git a/plugins/infrawallet-backend/src/service/MetricProvider.ts b/plugins/infrawallet-backend/src/service/MetricProvider.ts new file mode 100644 index 0000000..3681c76 --- /dev/null +++ b/plugins/infrawallet-backend/src/service/MetricProvider.ts @@ -0,0 +1,105 @@ +import { CacheService, DatabaseService, LoggerService } from '@backstage/backend-plugin-api'; +import { Config } from '@backstage/config'; +import { getMetricsFromCache, setMetricsToCache } from './functions'; +import { CloudProviderError, Metric, MetricQuery, MetricSetting, MetricResponse } from './types'; + +export abstract class MetricProvider { + constructor( + protected readonly providerName: string, + protected readonly config: Config, + protected readonly database: DatabaseService, + protected readonly cache: CacheService, + protected readonly logger: LoggerService, + ) {} + + abstract initProviderClient(metricProviderConfig: Config): Promise; + + abstract fetchMetrics(metricProviderConfig: Config, client: any, query: MetricQuery): Promise; + + abstract transformMetricData( + metricProviderConfig: Config, + query: MetricQuery, + metricResponse: any, + ): Promise; + + async getMetrics(query: MetricQuery): Promise { + const conf = this.config.getOptionalConfigArray( + `backend.infraWallet.metricProviders.${this.providerName.toLowerCase()}`, + ); + if (!conf) { + return { metrics: [], errors: [] }; + } + + const promises = []; + const results: Metric[] = []; + const errors: CloudProviderError[] = []; + + for (const c of conf) { + const configName = c.getString('name'); + const client = await this.initProviderClient(c); + const dbClient = await this.database.getClient(); + + const metricSettings = await dbClient + .where({ + 'wallets.name': query.walletName, + 'business_metrics.metric_provider': this.providerName.toLowerCase(), + 'business_metrics.config_name': configName, + }) + .select('business_metrics.*') + .from('business_metrics') + .join('wallets', 'business_metrics.wallet_id', '=', 'wallets.id'); + + for (const metric of metricSettings || []) { + const promise = (async () => { + try { + const fullQuery: MetricQuery = { + name: metric.metric_name, + query: metric.query, + ...query, + }; + + // first check if there is any cached metrics + const cachedMetrics = await getMetricsFromCache(this.cache, this.providerName, configName, fullQuery); + if (cachedMetrics) { + this.logger.debug(`${this.providerName}/${configName}/${fullQuery.name} metrics from cache`); + cachedMetrics.map(m => { + results.push(m); + }); + return; + } + + const metricResponse = await this.fetchMetrics(c, client, fullQuery); + const transformedMetrics = await this.transformMetricData(c, fullQuery, metricResponse); + + // cache the results for 2 hours + await setMetricsToCache( + this.cache, + transformedMetrics, + this.providerName, + configName, + fullQuery, + 60 * 60 * 2 * 1000, + ); + + transformedMetrics.map((value: any) => { + results.push(value); + }); + } catch (e) { + this.logger.error(e); + errors.push({ + provider: this.providerName, + name: `${this.providerName}/${configName}/${metric.getString('metricName')}`, + error: e.message, + }); + } + })(); + promises.push(promise); + } + } + await Promise.all(promises); + return { + metrics: results, + errors: errors, + }; + } +} diff --git a/plugins/infrawallet-backend/src/service/consts.ts b/plugins/infrawallet-backend/src/service/consts.ts index d4b390b..ac8a878 100644 --- a/plugins/infrawallet-backend/src/service/consts.ts +++ b/plugins/infrawallet-backend/src/service/consts.ts @@ -1,11 +1,20 @@ import { AwsClient } from './AwsClient'; import { AzureClient } from './AzureClient'; +import { DatadogProvider } from './DatadogProvider'; import { GCPClient } from './GCPClient'; +import { GrafanaCloudProvider } from './GrafanaCloudProvider'; -export const PROVIDER_CLIENT_MAPPINGS: { +export const COST_CLIENT_MAPPINGS: { [provider: string]: any; } = { aws: AwsClient, azure: AzureClient, gcp: GCPClient, }; + +export const METRIC_PROVIDER_MAPPINGS: { + [provider: string]: any; +} = { + datadog: DatadogProvider, + grafanacloud: GrafanaCloudProvider, +}; diff --git a/plugins/infrawallet-backend/src/service/functions.ts b/plugins/infrawallet-backend/src/service/functions.ts index 09504b8..3996e5c 100644 --- a/plugins/infrawallet-backend/src/service/functions.ts +++ b/plugins/infrawallet-backend/src/service/functions.ts @@ -1,5 +1,5 @@ import { CacheService, DatabaseService } from '@backstage/backend-plugin-api'; -import { CategoryMapping, CostQuery, Report } from './types'; +import { CategoryMapping, CostQuery, Metric, MetricQuery, Report } from './types'; export async function getCategoryMappings( database: DatabaseService, @@ -72,6 +72,25 @@ export async function getReportsFromCache( return cachedCosts; } +export async function getMetricsFromCache( + cache: CacheService, + provider: string, + configKey: string, + query: MetricQuery, +): Promise { + const cacheKey = [ + provider, + configKey, + query.name, + query.query, + query.granularity, + query.startTime, + query.endTime, + ].join('_'); + const cachedMetrics = (await cache.get(cacheKey)) as Metric[] | undefined; + return cachedMetrics; +} + export async function setReportsToCache( cache: CacheService, reports: Report[], @@ -93,3 +112,26 @@ export async function setReportsToCache( ttl: ttl ?? 60 * 60 * 2 * 1000, }); // cache for 2 hours by default } + +export async function setMetricsToCache( + cache: CacheService, + metrics: Metric[], + provider: string, + configKey: string, + query: MetricQuery, + ttl?: number, +) { + const cacheKey = [ + provider, + configKey, + query.name, + query.query, + query.granularity, + query.startTime, + query.endTime, + ].join('_'); + const crypto = require('crypto'); + await cache.set(crypto.createHash('md5').update(cacheKey).digest('hex'), metrics, { + ttl: ttl ?? 60 * 60 * 2 * 1000, + }); // cache for 2 hours by default +} diff --git a/plugins/infrawallet-backend/src/service/router.ts b/plugins/infrawallet-backend/src/service/router.ts index c2edf38..92c9953 100644 --- a/plugins/infrawallet-backend/src/service/router.ts +++ b/plugins/infrawallet-backend/src/service/router.ts @@ -3,9 +3,16 @@ import { CacheService, DatabaseService, LoggerService, resolvePackagePath } from import { Config } from '@backstage/config'; import express from 'express'; import Router from 'express-promise-router'; +import { + deleteWalletMetricSetting, + getWallet, + getWalletMetricSettings, + updateOrInsertWalletMetricSetting, +} from '../controllers/MetricSettingController'; import { InfraWalletClient } from './InfraWalletClient'; -import { PROVIDER_CLIENT_MAPPINGS } from './consts'; -import { CloudProviderError, Report } from './types'; +import { MetricProvider } from './MetricProvider'; +import { COST_CLIENT_MAPPINGS, METRIC_PROVIDER_MAPPINGS } from './consts'; +import { CloudProviderError, Metric, MetricSetting, Report } from './types'; export interface RouterOptions { logger: LoggerService; @@ -54,8 +61,8 @@ export async function createRouter(options: RouterOptions): Promise { - if (provider in PROVIDER_CLIENT_MAPPINGS) { - const client: InfraWalletClient = PROVIDER_CLIENT_MAPPINGS[provider].create(config, database, cache, logger); + if (provider in COST_CLIENT_MAPPINGS) { + const client: InfraWalletClient = COST_CLIENT_MAPPINGS[provider].create(config, database, cache, logger); const fetchCloudCosts = (async () => { try { const clientResponse = await client.getCostReports({ @@ -93,6 +100,97 @@ export async function createRouter(options: RouterOptions): Promise { + const walletName = request.params.walletName; + const granularity = request.query.granularity as string; + const startTime = request.query.startTime as string; + const endTime = request.query.endTime as string; + const promises: Promise[] = []; + const results: Metric[] = []; + const errors: CloudProviderError[] = []; + + const conf = config.getConfig('backend.infraWallet.metricProviders'); + conf.keys().forEach((provider: string) => { + if (provider in METRIC_PROVIDER_MAPPINGS) { + const client: MetricProvider = METRIC_PROVIDER_MAPPINGS[provider].create(config, database, cache, logger); + const fetchMetrics = (async () => { + try { + const metricResponse = await client.getMetrics({ + walletName: walletName, + granularity: granularity, + startTime: startTime, + endTime: endTime, + }); + metricResponse.errors.forEach((e: CloudProviderError) => { + errors.push(e); + }); + metricResponse.metrics.forEach((metric: Metric) => { + results.push(metric); + }); + } catch (e) { + logger.error(e); + errors.push({ + provider: client.constructor.name, + name: client.constructor.name, + error: e.message, + }); + } + })(); + promises.push(fetchMetrics); + } + }); + + await Promise.all(promises); + + if (errors.length > 0) { + response.status(207).json({ data: results, errors: errors, status: 207 }); + } else { + response.json({ data: results, errors: errors, status: 200 }); + } + }); + + router.get('/:walletName', async (request, response) => { + const walletName = request.params.walletName; + const wallet = await getWallet(database, walletName); + if (wallet === undefined) { + response.status(404).json({ error: 'Wallet not found', status: 404 }); + return; + } + + response.json({ data: wallet, status: 200 }); + }); + + router.get('/:walletName/metrics_setting', async (request, response) => { + const walletName = request.params.walletName; + const metricSettings = await getWalletMetricSettings(database, walletName); + response.json({ data: metricSettings, status: 200 }); + }); + + router.get('/metric/metric_configs', async (_request, response) => { + const conf = config.getConfig('backend.infraWallet.metricProviders'); + const configNames: { metric_provider: string; config_name: string }[] = []; + conf.keys().forEach((provider: string) => { + const configs = conf.getOptionalConfigArray(provider); + if (configs) { + configs.forEach(c => { + configNames.push({ metric_provider: provider, config_name: c.getString('name') }); + }); + } + }); + + response.json({ data: configNames, status: 200 }); + }); + + router.put('/:walletName/metrics_setting', async (request, response) => { + const updatedMetricSetting = await updateOrInsertWalletMetricSetting(database, request.body as MetricSetting); + response.json({ updated: updatedMetricSetting, status: 200 }); + }); + + router.delete('/:walletName/metrics_setting', async (request, response) => { + const deletedMetricSetting = await deleteWalletMetricSetting(database, request.body as MetricSetting); + response.json({ deleted: deletedMetricSetting, status: 200 }); + }); + router.use(errorHandler()); return router; } diff --git a/plugins/infrawallet-backend/src/service/types.ts b/plugins/infrawallet-backend/src/service/types.ts index 1142ffe..b1432e3 100644 --- a/plugins/infrawallet-backend/src/service/types.ts +++ b/plugins/infrawallet-backend/src/service/types.ts @@ -34,3 +34,43 @@ export type ClientResponse = { reports: Report[]; errors: CloudProviderError[]; }; + +export type MetricQuery = { + walletName: string; + name?: string; + query?: string; + startTime: string; + endTime: string; + granularity: string; +}; + +export type Metric = { + id: string; + provider: string; + name: string; + reports: { + [period: string]: number; + }; +}; + +export type MetricResponse = { + metrics: Metric[]; + errors: CloudProviderError[]; +}; + +export type MetricSetting = { + id: string; + wallet_id: string; + metric_provider: string; + config_name: string; + metric_name: string; + description?: string; + query: string; +}; + +export type Wallet = { + id: string; + name: string; + currenty: string; + description?: string; +}; diff --git a/plugins/infrawallet/README.md b/plugins/infrawallet/README.md index c811f93..9ab5c2a 100644 --- a/plugins/infrawallet/README.md +++ b/plugins/infrawallet/README.md @@ -28,6 +28,19 @@ infraWallet: defaultShowLastXMonths: 3 # 3 by default, or other numbers, we recommend it less than 12 ``` +### Default Settings for Frontend + +Site admins can configure the default view for InfraWallet, including the default group by dimension, and the default +query period. Add the following configurations to your `app-config.yaml` file if the default view needs to be changed. + +```yaml +# note that infraWallet exists at the root level, it is not the same one for backend configurations +infraWallet: + settings: + defaultGroupBy: none # none by default, or provider, category, service, tag: + defaultShowLastXMonths: 3 # 3 by default, or other numbers, we recommend it less than 12 +``` + ### Define Cloud Accounts in app-config.yaml The configuration schema of InfraWallet is defined in the [plugins/infrawallet-backend/config.d.ts](plugins/infrawallet-backend/config.d.ts) file. Users need to configure their cloud accounts in the `app-config.yaml` in the root folder. diff --git a/plugins/infrawallet/package.json b/plugins/infrawallet/package.json index 100b21e..f4e62f4 100644 --- a/plugins/infrawallet/package.json +++ b/plugins/infrawallet/package.json @@ -40,13 +40,13 @@ "@material-ui/core": "^4.9.13", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "^4.0.0-alpha.61", - "@mui/material": "^5.12.2", + "@mui/material": "^5.16.6", "@mui/x-data-grid": "7.3.1", "@mui/x-date-pickers": "^6.20.0", "@stitches/react": "1.2.8", "@types/react": "^18", "@viniarruda/react-month-range-picker": "2.0.4", - "apexcharts": "3.42.0", + "apexcharts": "3.51.0", "better-react-mathjax": "^2.0.2", "d3-shape": "3.2.0", "date-fns": "2.30.0", @@ -56,7 +56,8 @@ "mui-modal-provider": "2.2.0", "node-fetch": "^2.6.7", "react-apexcharts": "1.4.1", - "react-date-range": "1.4.0" + "react-date-range": "1.4.0", + "uuid": "10.0.0" }, "devDependencies": { "@backstage/cli": "^0.26.5", @@ -69,6 +70,7 @@ "@types/lodash": "^4.14.151", "@types/node-fetch": "^2.6.4", "@types/react-date-range": "1.4.5", + "@types/uuid": "10.0.0", "msw": "^1.0.0" }, "peerDependencies": { diff --git a/plugins/infrawallet/src/api/InfraWalletApi.ts b/plugins/infrawallet/src/api/InfraWalletApi.ts index 25d7bc6..43a7a9e 100644 --- a/plugins/infrawallet/src/api/InfraWalletApi.ts +++ b/plugins/infrawallet/src/api/InfraWalletApi.ts @@ -1,6 +1,12 @@ import { createApiRef } from '@backstage/core-plugin-api'; -import { CostReportsResponse } from './types'; -import { Response } from 'node-fetch'; +import { + CostReportsResponse, + GetWalletResponse, + MetricConfigsResponse, + MetricSetting, + MetricsResponse, + MetricsSettingResponse, +} from './types'; /** @public */ export const infraWalletApiRef = createApiRef({ @@ -9,7 +15,6 @@ export const infraWalletApiRef = createApiRef({ /** @public */ export interface InfraWalletApi { - get(path: string): Promise; getCostReports( filters: string, groups: string, @@ -17,4 +22,16 @@ export interface InfraWalletApi { startTime: Date, endTime: Date, ): Promise; + getMetrics(walletName: string, granularity: string, startTime: Date, endTime: Date): Promise; + getMetricConfigs(): Promise; + getWalletMetricsSetting(walletName: string): Promise; + updateWalletMetricSetting( + walletName: string, + metricSetting: MetricSetting, + ): Promise<{ updated: boolean; status: number }>; + deleteWalletMetricSetting( + walletName: string, + metricSetting: MetricSetting, + ): Promise<{ deleted: boolean; status: number }>; + getWalletByName(walletName: string): Promise; } diff --git a/plugins/infrawallet/src/api/InfraWalletApiClient.ts b/plugins/infrawallet/src/api/InfraWalletApiClient.ts index 515250e..46b3f16 100644 --- a/plugins/infrawallet/src/api/InfraWalletApiClient.ts +++ b/plugins/infrawallet/src/api/InfraWalletApiClient.ts @@ -1,7 +1,14 @@ import { ConfigApi, IdentityApi } from '@backstage/core-plugin-api'; import fetch from 'node-fetch'; import { InfraWalletApi } from './InfraWalletApi'; -import { CostReportsResponse } from './types'; +import { + CostReportsResponse, + GetWalletResponse, + MetricConfigsResponse, + MetricSetting, + MetricsResponse, + MetricsSettingResponse, +} from './types'; /** @public */ export class InfraWalletApiClient implements InfraWalletApi { @@ -13,16 +20,29 @@ export class InfraWalletApiClient implements InfraWalletApi { this.backendUrl = options.configApi.getString('backend.baseUrl'); } - async get(path: string): Promise { + async request(path: string, method?: string, payload?: Record) { const url = `${this.backendUrl}/${path}`; const { token: idToken } = await this.identityApi.getCredentials(); - const response = await fetch(url, { - headers: idToken ? { Authorization: `Bearer ${idToken}` } : {}, - }); + const headers: Record = idToken ? { Authorization: `Bearer ${idToken}` } : {}; + + if (method !== undefined && method !== 'GET') { + headers['Content-Type'] = 'application/json'; + } + + const request: any = { + headers: headers, + method: method ?? 'GET', + }; + + if (payload) { + request.body = JSON.stringify(payload); + } + + const response = await fetch(url, request); if (!response.ok) { - const payload = await response.text(); - const message = `Request failed with ${response.status} ${response.statusText}, ${payload}`; + const res = await response.text(); + const message = `Request failed with ${response.status} ${response.statusText}, ${res}`; throw new Error(message); } @@ -37,6 +57,41 @@ export class InfraWalletApiClient implements InfraWalletApi { endTime: Date, ): Promise { const url = `api/infrawallet/reports?&filters=${filters}&groups=${groups}&granularity=${granularity}&startTime=${startTime.getTime()}&endTime=${endTime.getTime()}`; - return await this.get(url); + return await this.request(url); + } + + async getWalletByName(walletName: string): Promise { + const url = `api/infrawallet/${walletName}`; + return await this.request(url); + } + + async getMetrics(walletName: string, granularity: string, startTime: Date, endTime: Date): Promise { + const url = `api/infrawallet/${walletName}/metrics?&granularity=${granularity}&startTime=${startTime.getTime()}&endTime=${endTime.getTime()}`; + return await this.request(url); + } + + async getMetricConfigs(): Promise { + const url = 'api/infrawallet/metric/metric_configs'; + return await this.request(url); + } + + async getWalletMetricsSetting(walletName: string): Promise { + const url = `api/infrawallet/${walletName}/metrics_setting`; + return await this.request(url); + } + async updateWalletMetricSetting( + walletName: string, + metricSetting: MetricSetting, + ): Promise<{ updated: boolean; status: number }> { + const url = `api/infrawallet/${walletName}/metrics_setting`; + return await this.request(url, 'PUT', metricSetting); + } + + async deleteWalletMetricSetting( + walletName: string, + metricSetting: MetricSetting, + ): Promise<{ deleted: boolean; status: number }> { + const url = `api/infrawallet/${walletName}/metrics_setting`; + return await this.request(url, 'DELETE', metricSetting); } } diff --git a/plugins/infrawallet/src/api/functions.ts b/plugins/infrawallet/src/api/functions.ts index ff342dc..c1b6d04 100644 --- a/plugins/infrawallet/src/api/functions.ts +++ b/plugins/infrawallet/src/api/functions.ts @@ -66,7 +66,7 @@ export const aggregateCostReports = (reports: Report[], aggregatedBy?: string): if (aggregatedBy && aggregatedBy in report) { keyName = report[aggregatedBy] as string; } else if (aggregatedBy === 'none') { - keyName = 'Total cloud costs'; + keyName = 'Total'; } if (!accumulator[keyName]) { diff --git a/plugins/infrawallet/src/api/types.ts b/plugins/infrawallet/src/api/types.ts index 714ec03..d2a25a5 100644 --- a/plugins/infrawallet/src/api/types.ts +++ b/plugins/infrawallet/src/api/types.ts @@ -21,3 +21,56 @@ export type CostReportsResponse = { errors?: CloudProviderError[]; status: number; }; + +// for now it is the same as type Report +// but still would like to keep them separate for future changes +export type Metric = { + id: string; + [dimension: string]: string | { [period: string]: number } | undefined; + reports: { + [period: string]: number; + }; +}; + +export type MetricConfig = { + metric_provider: string; + config_name: string; +}; + +export type MetricConfigsResponse = { + data?: MetricConfig[]; + status: number; +}; + +export type MetricsResponse = { + data?: Metric[]; + errors?: CloudProviderError[]; + status: number; +}; + +export type MetricSetting = { + id: string; + wallet_id: string; + metric_provider: string; + config_name: string; + metric_name: string; + description?: string; + query: string; +}; + +export type MetricsSettingResponse = { + data?: MetricSetting[]; + status: number; +}; + +export type Wallet = { + id: string; + name: string; + currenty: string; + description?: string; +}; + +export type GetWalletResponse = { + data?: Wallet; + status: number; +}; diff --git a/plugins/infrawallet/src/components/ColumnsChartComponent/ColumnsChartComponent.tsx b/plugins/infrawallet/src/components/ColumnsChartComponent/ColumnsChartComponent.tsx index b1a3fc2..25e68d8 100644 --- a/plugins/infrawallet/src/components/ColumnsChartComponent/ColumnsChartComponent.tsx +++ b/plugins/infrawallet/src/components/ColumnsChartComponent/ColumnsChartComponent.tsx @@ -1,48 +1,25 @@ import { Grid, Paper, Switch } from '@material-ui/core'; -import { withStyles, makeStyles, useTheme } from '@material-ui/core/styles'; +import { makeStyles, useTheme } from '@material-ui/core/styles'; import humanFormat from 'human-format'; -import React, { FC } from 'react'; +import React, { FC, useCallback, useEffect, useState } from 'react'; import Chart from 'react-apexcharts'; +import { colorList } from '../constants'; import { ColumnsChartComponentProps } from '../types'; -const Toggle = withStyles(theme => ({ - root: { - width: 28, - height: 16, - padding: 0, - display: 'flex', - }, - switchBase: { - padding: 2, - color: theme.palette.grey[500], - '&$checked': { - transform: 'translateX(12px)', - color: theme.palette.common.white, - '& + $track': { - opacity: 1, - backgroundColor: theme.palette.primary.main, - borderColor: theme.palette.primary.main, - }, - }, - }, - thumb: { - width: 12, - height: 12, - boxShadow: 'none', - }, - track: { - border: `1px solid ${theme.palette.grey[500]}`, - borderRadius: 16 / 2, - opacity: 1, - backgroundColor: theme.palette.common.white, - }, - checked: {}, -}))(Switch); +type CurveType = + | 'smooth' + | 'straight' + | 'stepline' + | 'linestep' + | 'monotoneCubic' + | ('smooth' | 'straight' | 'stepline' | 'linestep' | 'monotoneCubic')[] + | undefined; export const ColumnsChartComponent: FC = ({ granularitySetter, categories, series, + metrics, height, thumbnail, dataPointSelectionHandler, @@ -52,18 +29,22 @@ export const ColumnsChartComponent: FC = ({ fixedHeightPaper: { padding: '16px', display: 'flex', - overflow: 'auto', flexDirection: 'column', height: height ? height : 300, }, thumbnailPaper: { display: 'flex', - overflow: 'auto', + overflow: 'hidden', flexDirection: 'column', height: height ? height - 70 : 80, }, }); const classes = useStyles(); + const [showMetrics, setShowMetrics] = useState(true); + const [seriesArray, setSeriesArray] = useState([]); + const [yaxisArray, setYaxisArray] = useState([]); + const [strokeWidthArray, setStrokeWidthArray] = useState([]); + const [strokeDashArray, setStrokeDashArray] = useState([]); const customScale = humanFormat.Scale.create(['', 'K', 'M', 'B'], 1000); const state = thumbnail @@ -110,21 +91,32 @@ export const ColumnsChartComponent: FC = ({ xaxis: { categories: categories, }, - yaxis: { - decimalsInFloat: 2, + stroke: { + width: strokeWidthArray, + dashArray: strokeDashArray, + curve: 'smooth' as CurveType, }, + yaxis: yaxisArray, dataLabels: { enabled: false, }, tooltip: { y: { - formatter: (value: number) => { - return `$${humanFormat(value, { + formatter: (value: number, { seriesIndex }: { seriesIndex: number }) => { + if (!value) { + return ''; + } + const prefix = seriesIndex <= series.length - 1 ? '$' : ''; + return `${prefix}${humanFormat(value, { scale: customScale, separator: '', })}`; }, }, + fixed: { + enabled: true, + position: 'topRight', + }, }, legend: { showForSingleSeries: true, @@ -133,72 +125,90 @@ export const ColumnsChartComponent: FC = ({ mode: defaultTheme.palette.type, }, // there are only 5 colors by default, here we extend it to 50 different colors - colors: [ - '#008FFB', - '#00E396', - '#FEB019', - '#FF4560', - '#775DD0', - '#3F51B5', - '#03A9F4', - '#4CAF50', - '#F9CE1D', - '#FF9800', - '#33B2DF', - '#546E7A', - '#D4526E', - '#13D8AA', - '#A5978B', - '#4ECDC4', - '#C7F464', - '#81D4FA', - '#546E7A', - '#FD6A6A', - '#2B908F', - '#F9A3A4', - '#90EE7E', - '#FA4443', - '#69D2E7', - '#449DD1', - '#F86624', - '#EA3546', - '#662E9B', - '#C5D86D', - '#D7263D', - '#1B998B', - '#2E294E', - '#F46036', - '#E2C044', - '#662E9B', - '#F86624', - '#F9C80E', - '#EA3546', - '#43BCCD', - '#5C4742', - '#A5978B', - '#8D5B4C', - '#5A2A27', - '#C4BBAF', - '#A300D6', - '#7D02EB', - '#5653FE', - '#2983FF', - '#00B1F2', - ], + colors: colorList, }, - series: series, + series: seriesArray, }; + const initChartCallback = useCallback(async () => { + const strokeWidth = Array(series.length).fill(0); + const seriesResult = series.map(s => s); + // init a scale here as well, it seems that adding the predefined customScale as a dependency is buggy + const scale = humanFormat.Scale.create(['', 'K', 'M', 'B'], 1000); + const yaxisResult: any[] = [ + { + seriesName: series.map(s => s.name), + decimalsInFloat: 2, + title: { + text: 'Costs in USD', + }, + labels: { + formatter: (value: number) => { + if (typeof value !== 'number' || isNaN(value)) { + return ''; + } + return `$${humanFormat(value, { + scale: scale, + separator: '', + })}`; + }, + }, + }, + ]; + + if (metrics && showMetrics) { + metrics.forEach(metric => { + strokeWidth.push(3); + seriesResult.push(metric); + yaxisResult.push({ + seriesName: [metric.name], + decimalsInFloat: 2, + opposite: true, + title: { + text: metric.name, + }, + labels: { + formatter: (value: number) => { + if (typeof value !== 'number' || isNaN(value)) { + return ''; + } + return humanFormat(value, { + scale: scale, + separator: '', + }); + }, + }, + }); + }); + } + + setSeriesArray(seriesResult); + setYaxisArray(yaxisResult); + setStrokeWidthArray(strokeWidth); + setStrokeDashArray(Array(seriesResult.length).fill(0)); + }, [metrics, series, showMetrics]); + + useEffect(() => { + initChartCallback(); + }, [initChartCallback]); + return ( Monthly - granularitySetter(event.target.checked ? 'daily' : 'monthly')} /> + granularitySetter(event.target.checked ? 'daily' : 'monthly')} /> Daily + | + + setShowMetrics((ori: boolean) => !ori)} /> + + Show Metrics - + {seriesArray && ( + + )} ); }; diff --git a/plugins/infrawallet/src/components/MetricConfigurationComponent/MetricConfigurationComponent.tsx b/plugins/infrawallet/src/components/MetricConfigurationComponent/MetricConfigurationComponent.tsx new file mode 100644 index 0000000..a370b3f --- /dev/null +++ b/plugins/infrawallet/src/components/MetricConfigurationComponent/MetricConfigurationComponent.tsx @@ -0,0 +1,289 @@ +import { alertApiRef, useApi } from '@backstage/core-plugin-api'; +import AddIcon from '@material-ui/icons/Add'; +import CancelIcon from '@material-ui/icons/Close'; +import DeleteIcon from '@material-ui/icons/DeleteOutlined'; +import EditIcon from '@material-ui/icons/Edit'; +import SaveIcon from '@material-ui/icons/Save'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import React, { FC, useCallback, useEffect, useState } from 'react'; +import { v4 as uuidv4 } from 'uuid'; +import { infraWalletApiRef } from '../../api/InfraWalletApi'; +import { MetricConfig, MetricSetting, Wallet } from '../../api/types'; + +import { + DataGrid, + GridActionsCellItem, + GridColDef, + GridEventListener, + GridRowEditStopReasons, + GridRowId, + GridRowModel, + GridRowModes, + GridRowModesModel, + GridRowsProp, + GridSlots, + GridToolbarContainer, + ValueOptions, +} from '@mui/x-data-grid'; + +export const MetricConfigurationComponent: FC<{ wallet?: Wallet }> = ({ wallet }) => { + const alertApi = useApi(alertApiRef); + const infraWalletApi = useApi(infraWalletApiRef); + const [rows, setRows] = useState([]); + const [metricConfigs, setMetricConfigs] = useState(); + const [rowModesModel, setRowModesModel] = useState({}); + + function EditToolbar() { + const handleClick = () => { + const id = uuidv4(); + setRows(oldRows => [ + ...oldRows, + { + id, + wallet_id: wallet ? wallet.id : '', + metric_provider: '', + config_name: '', + metric_name: '', + description: '', + query: '', + isNew: true, + }, + ]); + setRowModesModel(oldModel => ({ + ...oldModel, + [id]: { mode: GridRowModes.Edit, fieldToFocus: 'name' }, + })); + }; + + return ( + + + + ); + } + + const handleRowEditStop: GridEventListener<'rowEditStop'> = (params, event) => { + if (params.reason === GridRowEditStopReasons.rowFocusOut) { + event.defaultMuiPrevented = true; + } + }; + + const handleEditClick = (id: GridRowId) => () => { + setRowModesModel({ ...rowModesModel, [id]: { mode: GridRowModes.Edit } }); + }; + + const handleSaveClick = (id: GridRowId) => () => { + setRowModesModel({ ...rowModesModel, [id]: { mode: GridRowModes.View } }); + }; + + const handleDeleteClick = (row: GridRowModel) => () => { + if (wallet) { + const { isNew, ...metricSetting } = row; + infraWalletApi + .deleteWalletMetricSetting(wallet.name, metricSetting as MetricSetting) + .then(response => { + if (response.status === 200) { + setRows(rows.filter(r => r.id !== row.id)); + } else { + alertApi.post({ message: 'Failed to update the metric setting', severity: 'error' }); + } + }) + .catch(e => alertApi.post({ message: `${e.message}`, severity: 'error' })); + } + }; + + const handleCancelClick = (id: GridRowId) => () => { + setRowModesModel({ + ...rowModesModel, + [id]: { mode: GridRowModes.View, ignoreModifications: true }, + }); + + const editedRow = rows.find(row => row.id === id); + if (editedRow!.isNew) { + setRows(rows.filter(row => row.id !== id)); + } + }; + + const processRowUpdate = (newRow: GridRowModel) => { + const updatedRow = { ...newRow, isNew: false }; + if (wallet) { + const { isNew, ...metricSetting } = updatedRow; + + infraWalletApi + .updateWalletMetricSetting(wallet.name, metricSetting as MetricSetting) + .then(response => { + if (response.status === 200) { + setRows(rows.map(row => (row.id === newRow.id ? updatedRow : row))); + } else { + alertApi.post({ message: 'Failed to update the metric setting', severity: 'error' }); + } + }) + .catch(e => alertApi.post({ message: `${e.message}`, severity: 'error' })); + } + + return updatedRow; + }; + + const handleRowModesModelChange = (newRowModesModel: GridRowModesModel) => { + setRowModesModel(newRowModesModel); + }; + + const columns: GridColDef[] = [ + { + field: 'metric_provider', + headerName: 'Provider', + width: 220, + editable: true, + type: 'singleSelect', + valueOptions: () => { + const options: ValueOptions[] = []; + const optionsSet = new Set(); + + if (metricConfigs) { + metricConfigs.forEach(c => { + optionsSet.add(c.metric_provider); + }); + } + optionsSet.forEach(o => options.push({ value: o, label: o })); + return options; + }, + }, + { + field: 'config_name', + headerName: 'ConfigName', + width: 180, + editable: true, + type: 'singleSelect', + valueOptions: params => { + const options: ValueOptions[] = []; + if (params.row.metric_provider !== '' && metricConfigs) { + metricConfigs.forEach(c => { + if (params.row.metric_provider === c.metric_provider) { + options.push({ value: c.config_name, label: c.config_name }); + } + }); + } + return options; + }, + }, + { + field: 'metric_name', + headerName: 'MetricName', + width: 220, + editable: true, + }, + { + field: 'description', + headerName: 'Description', + width: 220, + editable: true, + }, + { + field: 'query', + headerName: 'Query', + flex: 1, + editable: true, + }, + { + field: 'actions', + type: 'actions', + headerName: 'Actions', + width: 100, + cellClassName: 'actions', + getActions: ({ id, row }) => { + const isInEditMode = rowModesModel[id]?.mode === GridRowModes.Edit; + + if (isInEditMode) { + return [ + } + label="Save" + sx={{ + color: 'primary.main', + }} + onClick={handleSaveClick(id)} + />, + } + label="Cancel" + className="textPrimary" + onClick={handleCancelClick(id)} + color="inherit" + />, + ]; + } + + return [ + } + label="Edit" + className="textPrimary" + onClick={handleEditClick(id)} + color="inherit" + />, + } label="Delete" onClick={handleDeleteClick(row)} color="inherit" />, + ]; + }, + }, + ]; + + const getWalletMetricSettings = useCallback(async () => { + if (wallet) { + await infraWalletApi + .getWalletMetricsSetting(wallet.name) + .then(metricsResponse => { + if (metricsResponse.data && metricsResponse.status === 200) { + setRows(metricsResponse.data); + } + }) + .catch(e => alertApi.post({ message: `${e.message}`, severity: 'error' })); + } + }, [wallet, infraWalletApi, alertApi]); + + const getMetricConfig = useCallback(async () => { + await infraWalletApi + .getMetricConfigs() + .then(response => { + if (response.data && response.status === 200) { + setMetricConfigs(response.data); + } + }) + .catch(e => alertApi.post({ message: `${e.message}`, severity: 'error' })); + }, [alertApi, infraWalletApi]); + + useEffect(() => { + getWalletMetricSettings(); + getMetricConfig(); + }, [getWalletMetricSettings, getMetricConfig]); + + return ( + + + + ); +}; diff --git a/plugins/infrawallet/src/components/MetricConfigurationComponent/index.ts b/plugins/infrawallet/src/components/MetricConfigurationComponent/index.ts new file mode 100644 index 0000000..1bcbcfe --- /dev/null +++ b/plugins/infrawallet/src/components/MetricConfigurationComponent/index.ts @@ -0,0 +1 @@ +export { MetricConfigurationComponent } from './MetricConfigurationComponent'; diff --git a/plugins/infrawallet/src/components/PieChartComponent/PieChartComponent.tsx b/plugins/infrawallet/src/components/PieChartComponent/PieChartComponent.tsx index ea05748..e094ece 100644 --- a/plugins/infrawallet/src/components/PieChartComponent/PieChartComponent.tsx +++ b/plugins/infrawallet/src/components/PieChartComponent/PieChartComponent.tsx @@ -3,13 +3,13 @@ import { makeStyles } from '@material-ui/core/styles'; import humanFormat from 'human-format'; import React, { FC } from 'react'; import Chart from 'react-apexcharts'; +import { colorList } from '../constants'; import { PieChartComponentProps } from '../types'; export const PieChartComponent: FC = ({ categories, series, height }) => { const useStyles = makeStyles({ fixedHeightPaper: { paddingTop: '10px', - overflow: 'hidden', height: height ? height : 300, }, }); @@ -62,58 +62,7 @@ export const PieChartComponent: FC = ({ categories, seri }, }, // there are only 5 colors by default, here we extend it to 50 different colors - colors: [ - '#008FFB', - '#00E396', - '#FEB019', - '#FF4560', - '#775DD0', - '#3F51B5', - '#03A9F4', - '#4CAF50', - '#F9CE1D', - '#FF9800', - '#33B2DF', - '#546E7A', - '#D4526E', - '#13D8AA', - '#A5978B', - '#4ECDC4', - '#C7F464', - '#81D4FA', - '#546E7A', - '#FD6A6A', - '#2B908F', - '#F9A3A4', - '#90EE7E', - '#FA4443', - '#69D2E7', - '#449DD1', - '#F86624', - '#EA3546', - '#662E9B', - '#C5D86D', - '#D7263D', - '#1B998B', - '#2E294E', - '#F46036', - '#E2C044', - '#662E9B', - '#F86624', - '#F9C80E', - '#EA3546', - '#43BCCD', - '#5C4742', - '#A5978B', - '#8D5B4C', - '#5A2A27', - '#C4BBAF', - '#A300D6', - '#7D02EB', - '#5653FE', - '#2983FF', - '#00B1F2', - ], + colors: colorList, }, series: series, }; diff --git a/plugins/infrawallet/src/components/ReportsComponent/ReportsComponent.tsx b/plugins/infrawallet/src/components/ReportsComponent/ReportsComponent.tsx index 96406a5..341a4e1 100644 --- a/plugins/infrawallet/src/components/ReportsComponent/ReportsComponent.tsx +++ b/plugins/infrawallet/src/components/ReportsComponent/ReportsComponent.tsx @@ -8,6 +8,7 @@ import Typography from '@material-ui/core/Typography'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import { addMonths, endOfMonth, startOfMonth } from 'date-fns'; import React, { useCallback, useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; import { infraWalletApiRef } from '../../api/InfraWalletApi'; import { aggregateCostReports, @@ -16,7 +17,7 @@ import { getPeriodStrings, mergeCostReports, } from '../../api/functions'; -import { CloudProviderError, Filters, Report } from '../../api/types'; +import { CloudProviderError, Filters, Metric, Report } from '../../api/types'; import { ColumnsChartComponent } from '../ColumnsChartComponent'; import { CostReportsTableComponent } from '../CostReportsTableComponent'; import { ErrorsAlertComponent } from '../ErrorsAlertComponent'; @@ -57,6 +58,7 @@ const checkIfFiltersActivated = (filters: Filters): boolean => { export const ReportsComponent = () => { const configApi = useApi(configApiRef); + const params = useParams(); const defaultGroupBy = configApi.getOptionalString('infraWallet.settings.defaultGroupBy') ?? 'none'; const defaultShowLastXMonths = configApi.getOptionalNumber('infraWallet.settings.defaultShowLastXMonths') ?? 3; @@ -64,6 +66,7 @@ export const ReportsComponent = () => { const MERGE_THRESHOLD = 8; const [submittingState, setSubmittingState] = useState(false); const [reports, setReports] = useState([]); + const [metrics, setMetrics] = useState([]); const [filters, setFilters] = useState({}); const [cloudProviderErrors, setCloudProviderErrors] = useState([]); const [reportsAggregated, setReportsAggregated] = useState([]); @@ -98,6 +101,20 @@ export const ReportsComponent = () => { setSubmittingState(false); }, [groups, monthRangeState, granularity, infraWalletApi, alertApi]); + const fetchMetricsCallback = useCallback(async () => { + await infraWalletApi + .getMetrics(params.name ?? 'default', granularity, monthRangeState.startMonth, monthRangeState.endMonth) + .then(metricsResponse => { + if (metricsResponse.data && metricsResponse.data.length > 0) { + setMetrics(metricsResponse.data); + } + if (metricsResponse.status === 207 && metricsResponse.errors) { + setCloudProviderErrors(metricsResponse.errors); + } + }) + .catch(e => alertApi.post({ message: `${e.message}`, severity: 'error' })); + }, [params.name, monthRangeState, granularity, infraWalletApi, alertApi]); + useEffect(() => { if (reports.length !== 0) { const filteredReports = filterCostReports(reports, filters); @@ -112,7 +129,8 @@ export const ReportsComponent = () => { useEffect(() => { fetchCostReportsCallback(); - }, [fetchCostReportsCallback]); + fetchMetricsCallback(); + }, [fetchCostReportsCallback, fetchMetricsCallback]); return ( @@ -162,6 +180,12 @@ export const ReportsComponent = () => { categories={periods} series={reportsAggregatedAndMerged.map((item: any) => ({ name: item.id, + type: 'column', + data: rearrangeData(item, periods), + }))} + metrics={metrics.map((item: any) => ({ + name: item.id, + type: 'line', data: rearrangeData(item, periods), }))} height={350} diff --git a/plugins/infrawallet/src/components/SettingsComponent/SettingsComponent.tsx b/plugins/infrawallet/src/components/SettingsComponent/SettingsComponent.tsx new file mode 100644 index 0000000..8ca4137 --- /dev/null +++ b/plugins/infrawallet/src/components/SettingsComponent/SettingsComponent.tsx @@ -0,0 +1,49 @@ +import { Content, Header, Page } from '@backstage/core-components'; +import React, { useCallback, useEffect, useState } from 'react'; +import { alertApiRef, useApi } from '@backstage/core-plugin-api'; +import { infraWalletApiRef } from '../../api/InfraWalletApi'; +import { Grid } from '@material-ui/core'; +import Alert from '@material-ui/lab/Alert'; +import { useParams } from 'react-router-dom'; +import { MetricConfigurationComponent } from '../MetricConfigurationComponent'; +import { Wallet } from '../../api/types'; + +export const SettingsComponent = () => { + const params = useParams(); + const alertApi = useApi(alertApiRef); + const infraWalletApi = useApi(infraWalletApiRef); + const [wallet, setWallet] = useState(); + + const getWalletInfo = useCallback(async () => { + await infraWalletApi + .getWalletByName(params.name ?? 'default') + .then(getWalletResponse => { + if (getWalletResponse.data && getWalletResponse.status === 200) { + setWallet(getWalletResponse.data); + } + }) + .catch(e => alertApi.post({ message: `${e.message}`, severity: 'error' })); + }, [params.name, infraWalletApi, alertApi]); + + useEffect(() => { + getWalletInfo(); + }, [getWalletInfo]); + + return ( + +
+ + + + + For now, this page only supports configuring business metrics for the default wallet. + + + + + + + + + ); +}; diff --git a/plugins/infrawallet/src/components/SettingsComponent/index.ts b/plugins/infrawallet/src/components/SettingsComponent/index.ts new file mode 100644 index 0000000..398921a --- /dev/null +++ b/plugins/infrawallet/src/components/SettingsComponent/index.ts @@ -0,0 +1 @@ +export { SettingsComponent } from './SettingsComponent'; diff --git a/plugins/infrawallet/src/components/constants.tsx b/plugins/infrawallet/src/components/constants.tsx new file mode 100644 index 0000000..8642bf6 --- /dev/null +++ b/plugins/infrawallet/src/components/constants.tsx @@ -0,0 +1,53 @@ +// colors used by the charts +export const colorList = [ + '#008FFB', + '#00E396', + '#FEB019', + '#FF4560', + '#775DD0', + '#3F51B5', + '#03A9F4', + '#4CAF50', + '#F9CE1D', + '#FF9800', + '#33B2DF', + '#546E7A', + '#D4526E', + '#13D8AA', + '#A5978B', + '#4ECDC4', + '#C7F464', + '#81D4FA', + '#546E7A', + '#FD6A6A', + '#2B908F', + '#F9A3A4', + '#90EE7E', + '#FA4443', + '#69D2E7', + '#449DD1', + '#F86624', + '#EA3546', + '#662E9B', + '#C5D86D', + '#D7263D', + '#1B998B', + '#2E294E', + '#F46036', + '#E2C044', + '#662E9B', + '#F86624', + '#F9C80E', + '#EA3546', + '#43BCCD', + '#5C4742', + '#A5978B', + '#8D5B4C', + '#5A2A27', + '#C4BBAF', + '#A300D6', + '#7D02EB', + '#5653FE', + '#2983FF', + '#00B1F2', +]; diff --git a/plugins/infrawallet/src/components/types.ts b/plugins/infrawallet/src/components/types.ts index 8cce1f7..98c7ac0 100644 --- a/plugins/infrawallet/src/components/types.ts +++ b/plugins/infrawallet/src/components/types.ts @@ -37,6 +37,7 @@ export type ColumnsChartComponentProps = { granularitySetter: any; categories: any[]; series: Array<{ name: string; data: any[] }>; + metrics?: Array<{ name: string; data: any[] }>; height?: number; thumbnail?: boolean; dataPointSelectionHandler?: (event: any, chartContext: any, config: any) => void; @@ -53,3 +54,15 @@ export type CostReportsTableComponentProps = { aggregatedBy: string; periods: string[]; }; + +export type Metric = { + metricProvider: 'datadog' | 'grafanacloud'; + metricName: string; + description?: string; + query: string; +}; + +export type MetricCardProps = { + metric: Metric; + callback: Function; +}; diff --git a/plugins/infrawallet/src/routes.tsx b/plugins/infrawallet/src/routes.tsx index ac1782b..c60ee90 100644 --- a/plugins/infrawallet/src/routes.tsx +++ b/plugins/infrawallet/src/routes.tsx @@ -2,13 +2,17 @@ import { createRouteRef } from '@backstage/core-plugin-api'; import React from 'react'; import { Route, Routes } from 'react-router-dom'; import { ReportsComponent } from './components/ReportsComponent'; +import { SettingsComponent } from './components/SettingsComponent'; export const rootRouteRef = createRouteRef({ id: 'infrawallet', + params: ['name'], }); export const RootRoute = () => ( } /> + } /> + } /> ); diff --git a/yarn.lock b/yarn.lock index fd37226..0583f1f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5171,6 +5171,24 @@ __metadata: languageName: node linkType: hard +"@datadog/datadog-api-client@npm:^1.26.0": + version: 1.26.0 + resolution: "@datadog/datadog-api-client@npm:1.26.0" + dependencies: + "@types/buffer-from": ^1.1.0 + "@types/node": "*" + "@types/pako": ^1.0.3 + buffer-from: ^1.1.2 + cross-fetch: ^3.1.5 + es6-promise: ^4.2.8 + form-data: ^4.0.0 + loglevel: ^1.8.1 + pako: ^2.0.4 + url-parse: ^1.4.3 + checksum: 9b4ea43156d1d1918df83390399387882bb23852f6c1e440d06de30a588df0ba7115065afa219aad0e426b21930a408d92436db4ed55379536f78f69b8d72778 + languageName: node + linkType: hard + "@date-io/core@npm:1.x, @date-io/core@npm:^1.3.13": version: 1.3.13 resolution: "@date-io/core@npm:1.3.13" @@ -5218,6 +5236,7 @@ __metadata: "@backstage/plugin-auth-backend": ^0.22.5 "@backstage/plugin-auth-backend-module-guest-provider": ^0.1.4 "@backstage/types": ^1.1.1 + "@datadog/datadog-api-client": ^1.26.0 "@google-cloud/bigquery": 7.7.1 "@types/express": "*" "@types/supertest": ^2.0.8 @@ -5248,7 +5267,7 @@ __metadata: "@material-ui/core": ^4.9.13 "@material-ui/icons": ^4.9.1 "@material-ui/lab": ^4.0.0-alpha.61 - "@mui/material": ^5.12.2 + "@mui/material": ^5.16.6 "@mui/x-data-grid": 7.3.1 "@mui/x-date-pickers": ^6.20.0 "@stitches/react": 1.2.8 @@ -5259,8 +5278,9 @@ __metadata: "@types/node-fetch": ^2.6.4 "@types/react": ^18 "@types/react-date-range": 1.4.5 + "@types/uuid": 10.0.0 "@viniarruda/react-month-range-picker": 2.0.4 - apexcharts: 3.42.0 + apexcharts: 3.51.0 better-react-mathjax: ^2.0.2 d3-shape: 3.2.0 date-fns: 2.30.0 @@ -5272,6 +5292,7 @@ __metadata: node-fetch: ^2.6.7 react-apexcharts: 1.4.1 react-date-range: 1.4.0 + uuid: 10.0.0 peerDependencies: react: ^16.13.1 || ^17.0.0 || ^18.0.0 react-dom: ^17.0.0 || ^18.0.0 @@ -7199,28 +7220,6 @@ __metadata: languageName: node linkType: hard -"@mui/base@npm:5.0.0-beta.40": - version: 5.0.0-beta.40 - resolution: "@mui/base@npm:5.0.0-beta.40" - dependencies: - "@babel/runtime": ^7.23.9 - "@floating-ui/react-dom": ^2.0.8 - "@mui/types": ^7.2.14 - "@mui/utils": ^5.15.14 - "@popperjs/core": ^2.11.8 - clsx: ^2.1.0 - prop-types: ^15.8.1 - peerDependencies: - "@types/react": ^17.0.0 || ^18.0.0 - react: ^17.0.0 || ^18.0.0 - react-dom: ^17.0.0 || ^18.0.0 - peerDependenciesMeta: - "@types/react": - optional: true - checksum: 9c084ee67de372411a71af5eca9a5367db9f5bce57bb43973629c522760fe64fa2a43d2934dccd24d6dcbcd0ed399c5fc5c461226c86104f5767de1c9b8deba2 - languageName: node - linkType: hard - "@mui/base@npm:^5.0.0-beta.22": version: 5.0.0-beta.46 resolution: "@mui/base@npm:5.0.0-beta.46" @@ -7243,28 +7242,28 @@ __metadata: languageName: node linkType: hard -"@mui/core-downloads-tracker@npm:^5.15.17": - version: 5.15.17 - resolution: "@mui/core-downloads-tracker@npm:5.15.17" - checksum: 246bf7ee7ed25709006edd92fd344f57cdd9dec0b570df9f41127f87d15ee890af641eb8c646b4cd52972c71c273e8b564b999083b9828510ce72eccb153a0fd +"@mui/core-downloads-tracker@npm:^5.16.6": + version: 5.16.6 + resolution: "@mui/core-downloads-tracker@npm:5.16.6" + checksum: b8a4fda2130d3e4326a5c901f0b6e867f2e99395805e67bf577810f24f43624aa45f9ad00f7c1b2a3ec3f9b54f16cfbe56e2795ceba5fb79bacf00096f2a0ddc languageName: node linkType: hard -"@mui/material@npm:^5.12.2": - version: 5.15.17 - resolution: "@mui/material@npm:5.15.17" +"@mui/material@npm:^5.12.2, @mui/material@npm:^5.16.6": + version: 5.16.6 + resolution: "@mui/material@npm:5.16.6" dependencies: "@babel/runtime": ^7.23.9 - "@mui/base": 5.0.0-beta.40 - "@mui/core-downloads-tracker": ^5.15.17 - "@mui/system": ^5.15.15 - "@mui/types": ^7.2.14 - "@mui/utils": ^5.15.14 + "@mui/core-downloads-tracker": ^5.16.6 + "@mui/system": ^5.16.6 + "@mui/types": ^7.2.15 + "@mui/utils": ^5.16.6 + "@popperjs/core": ^2.11.8 "@types/react-transition-group": ^4.4.10 clsx: ^2.1.0 csstype: ^3.1.3 prop-types: ^15.8.1 - react-is: ^18.2.0 + react-is: ^18.3.1 react-transition-group: ^4.4.5 peerDependencies: "@emotion/react": ^11.5.0 @@ -7279,16 +7278,16 @@ __metadata: optional: true "@types/react": optional: true - checksum: 4738f357f10a8d88e4efd09833e59beece1872dea6aabc7176cf1d44cf38ea1c47c3a23ce8a7c3ec8b3f31132a755f1feb9de6c3cf440af4abec8fdffeabfb46 + checksum: 3bbc0141069dd7efff1d83294903788e482a6834dc3b442c29e19145aa8ad01190a00ce55c777a5560b1740559c606adf09eb2c5d133a4bdefb2e6e89a2382cb languageName: node linkType: hard -"@mui/private-theming@npm:^5.15.14": - version: 5.15.14 - resolution: "@mui/private-theming@npm:5.15.14" +"@mui/private-theming@npm:^5.16.6": + version: 5.16.6 + resolution: "@mui/private-theming@npm:5.16.6" dependencies: "@babel/runtime": ^7.23.9 - "@mui/utils": ^5.15.14 + "@mui/utils": ^5.16.6 prop-types: ^15.8.1 peerDependencies: "@types/react": ^17.0.0 || ^18.0.0 @@ -7296,13 +7295,13 @@ __metadata: peerDependenciesMeta: "@types/react": optional: true - checksum: 1b1ef54e8281c9b13fcc58f4c39682efc610946a68402283c19fcfbce8a7d7a231d61b536d6df9bf7a59a1426591bd403a453a59eb8efb9689437fb58554dc8c + checksum: 314ba598ab17cd425a36e4cab677ed26fe0939b23e53120da77cfbc3be6dada5428fa8e2a55cb697417599a4e3abfee6d4711de0a7318b9fb2c3a822b2d5b5a8 languageName: node linkType: hard -"@mui/styled-engine@npm:^5.15.14": - version: 5.15.14 - resolution: "@mui/styled-engine@npm:5.15.14" +"@mui/styled-engine@npm:^5.16.6": + version: 5.16.6 + resolution: "@mui/styled-engine@npm:5.16.6" dependencies: "@babel/runtime": ^7.23.9 "@emotion/cache": ^11.11.0 @@ -7317,19 +7316,19 @@ __metadata: optional: true "@emotion/styled": optional: true - checksum: 23b45c859a4f0d2b10933d06a6082c0ff093f7b6d8d32a2bfe3a6e515fe46d7a38ca9e7150d45c025a2e98d963bae9a5991d131cf4748b62670075ef0fa321ed + checksum: 604f83b91801945336db211a8273061132668d01e9f456c30bb811a3b49cc5786b8b7dd8e0b5b89de15f6209abc900d9e679d3ae7a4651a6df45e323b6ed95c5 languageName: node linkType: hard -"@mui/system@npm:^5.15.14, @mui/system@npm:^5.15.15": - version: 5.15.15 - resolution: "@mui/system@npm:5.15.15" +"@mui/system@npm:^5.15.14, @mui/system@npm:^5.16.6": + version: 5.16.6 + resolution: "@mui/system@npm:5.16.6" dependencies: "@babel/runtime": ^7.23.9 - "@mui/private-theming": ^5.15.14 - "@mui/styled-engine": ^5.15.14 - "@mui/types": ^7.2.14 - "@mui/utils": ^5.15.14 + "@mui/private-theming": ^5.16.6 + "@mui/styled-engine": ^5.16.6 + "@mui/types": ^7.2.15 + "@mui/utils": ^5.16.6 clsx: ^2.1.0 csstype: ^3.1.3 prop-types: ^15.8.1 @@ -7345,37 +7344,39 @@ __metadata: optional: true "@types/react": optional: true - checksum: 9ca96d5f66b2a9d6471909cc98c671eea5ec0a6d58a7ec071073b9e5200b95c3f017f0ca5cc946abc7f83074bd11830ca18f5e30bc98e25cd6ca217bd1b3a26f + checksum: 4babc4596ade3ca2408ea5ed833599d00b55605fe043cbeb8f85b92a3683beef429686ff864ee535080dff3d2dbe419f4467e50f4af9d6e6cae3dee6a357f157 languageName: node linkType: hard -"@mui/types@npm:^7.2.14": - version: 7.2.14 - resolution: "@mui/types@npm:7.2.14" +"@mui/types@npm:^7.2.14, @mui/types@npm:^7.2.15": + version: 7.2.15 + resolution: "@mui/types@npm:7.2.15" peerDependencies: "@types/react": ^17.0.0 || ^18.0.0 peerDependenciesMeta: "@types/react": optional: true - checksum: 615c9f9110933157f5d3c4fee69d6e70b98fc0d9ebc3b63079b6a1e23e6b389748687a25ab4ac15b56166fc228885da87c3929503b41fa322cfdee0f6d411206 + checksum: 86c7e58a4ead970204b746e3ead71d4b9b3ea1eebe237be88ae0d6b3fda958fb5aa73c238960c8c2b2cdb1cf424a961299a2292e8d7364ddb41bd20059b70993 languageName: node linkType: hard -"@mui/utils@npm:^5.14.15, @mui/utils@npm:^5.14.16, @mui/utils@npm:^5.15.14": - version: 5.15.14 - resolution: "@mui/utils@npm:5.15.14" +"@mui/utils@npm:^5.14.15, @mui/utils@npm:^5.14.16, @mui/utils@npm:^5.15.14, @mui/utils@npm:^5.16.6": + version: 5.16.6 + resolution: "@mui/utils@npm:5.16.6" dependencies: "@babel/runtime": ^7.23.9 - "@types/prop-types": ^15.7.11 + "@mui/types": ^7.2.15 + "@types/prop-types": ^15.7.12 + clsx: ^2.1.1 prop-types: ^15.8.1 - react-is: ^18.2.0 + react-is: ^18.3.1 peerDependencies: "@types/react": ^17.0.0 || ^18.0.0 react: ^17.0.0 || ^18.0.0 peerDependenciesMeta: "@types/react": optional: true - checksum: 36543ba7e3b65fb3219ed27e8f1455aff15b47a74c9b642c63e60774e22baa6492a196079e72bcfa5a570421dab32160398f892110bd444428bcf8b266b11893 + checksum: 6f8068f07f60a842fcb2e2540eecbd9c5f04df695bcc427184720e8ae138ae689fefd3c20147ab7c76e809ede6e10f5e08d1c34cd3a8b09bd22d2020a666a96f languageName: node linkType: hard @@ -11717,6 +11718,15 @@ __metadata: languageName: node linkType: hard +"@types/buffer-from@npm:^1.1.0": + version: 1.1.3 + resolution: "@types/buffer-from@npm:1.1.3" + dependencies: + "@types/node": "*" + checksum: 7267c0262aac8139eaf437e6e2558bd422028a268daff65afde81f444f551663e53af6d9f62d66ae13a0421a68ecccd4a3c19052cbcb21bd77e35f907446ebf5 + languageName: node + linkType: hard + "@types/cacheable-request@npm:^6.0.1": version: 6.0.3 resolution: "@types/cacheable-request@npm:6.0.3" @@ -12207,6 +12217,13 @@ __metadata: languageName: node linkType: hard +"@types/pako@npm:^1.0.3": + version: 1.0.7 + resolution: "@types/pako@npm:1.0.7" + checksum: 8920d0fa6f2f78d6f6e30f7e00d5ac91031a6ef6761ae2904dd503d395e954f856265db500b4a45918db407f07525c4fb686d45685a31667c7ecef9a3fedc447 + languageName: node + linkType: hard + "@types/parse-json@npm:^4.0.0": version: 4.0.2 resolution: "@types/parse-json@npm:4.0.2" @@ -12244,7 +12261,7 @@ __metadata: languageName: node linkType: hard -"@types/prop-types@npm:*, @types/prop-types@npm:^15.0.0, @types/prop-types@npm:^15.7.11, @types/prop-types@npm:^15.7.12, @types/prop-types@npm:^15.7.3": +"@types/prop-types@npm:*, @types/prop-types@npm:^15.0.0, @types/prop-types@npm:^15.7.12, @types/prop-types@npm:^15.7.3": version: 15.7.12 resolution: "@types/prop-types@npm:15.7.12" checksum: ac16cc3d0a84431ffa5cfdf89579ad1e2269549f32ce0c769321fdd078f84db4fbe1b461ed5a1a496caf09e637c0e367d600c541435716a55b1d9713f5035dfe @@ -12544,6 +12561,13 @@ __metadata: languageName: node linkType: hard +"@types/uuid@npm:10.0.0": + version: 10.0.0 + resolution: "@types/uuid@npm:10.0.0" + checksum: e3958f8b0fe551c86c14431f5940c3470127293280830684154b91dc7eb3514aeb79fe3216968833cf79d4d1c67f580f054b5be2cd562bebf4f728913e73e944 + languageName: node + linkType: hard + "@types/webpack-env@npm:^1.15.2": version: 1.18.5 resolution: "@types/webpack-env@npm:1.18.5" @@ -13513,9 +13537,9 @@ __metadata: languageName: node linkType: hard -"apexcharts@npm:3.42.0": - version: 3.42.0 - resolution: "apexcharts@npm:3.42.0" +"apexcharts@npm:3.51.0": + version: 3.51.0 + resolution: "apexcharts@npm:3.51.0" dependencies: "@yr/monotone-cubic-spline": ^1.0.3 svg.draggable.js: ^2.2.2 @@ -13524,7 +13548,7 @@ __metadata: svg.pathmorphing.js: ^0.1.3 svg.resize.js: ^1.4.3 svg.select.js: ^3.0.1 - checksum: e29ce35d9b8ad8c06fbda6b0350337ee7ccbd540bc08d5edb73dcaa97295340ca00509159a5be70273a25cd0a910e44bf08b71e5d37909ff5d26f0d7c825cbd5 + checksum: 13361009a9207441baf9b6fffa61266bb02b9e1f2dcc11ef707e2426a7ca9e24bb0c44ae407e4c61aa1128f61e912243509e4266c0512939658c05cf11ddcf8b languageName: node linkType: hard @@ -14670,7 +14694,7 @@ __metadata: languageName: node linkType: hard -"buffer-from@npm:^1.0.0": +"buffer-from@npm:^1.0.0, buffer-from@npm:^1.1.2": version: 1.1.2 resolution: "buffer-from@npm:1.1.2" checksum: 0448524a562b37d4d7ed9efd91685a5b77a50672c556ea254ac9a6d30e3403a517d8981f10e565db24e8339413b43c97ca2951f10e399c6125a0d8911f5679bb @@ -16209,6 +16233,15 @@ __metadata: languageName: node linkType: hard +"cross-fetch@npm:^3.1.5": + version: 3.1.8 + resolution: "cross-fetch@npm:3.1.8" + dependencies: + node-fetch: ^2.6.12 + checksum: 78f993fa099eaaa041122ab037fe9503ecbbcb9daef234d1d2e0b9230a983f64d645d088c464e21a247b825a08dc444a6e7064adfa93536d3a9454b4745b3632 + languageName: node + linkType: hard + "cross-fetch@npm:^4.0.0": version: 4.0.0 resolution: "cross-fetch@npm:4.0.0" @@ -17780,6 +17813,13 @@ __metadata: languageName: node linkType: hard +"es6-promise@npm:^4.2.8": + version: 4.2.8 + resolution: "es6-promise@npm:4.2.8" + checksum: 95614a88873611cb9165a85d36afa7268af5c03a378b35ca7bda9508e1d4f1f6f19a788d4bc755b3fd37c8ebba40782018e02034564ff24c9d6fa37e959ad57d + languageName: node + linkType: hard + "esbuild-loader@npm:^4.0.0": version: 4.1.0 resolution: "esbuild-loader@npm:4.1.0" @@ -23531,6 +23571,13 @@ __metadata: languageName: node linkType: hard +"loglevel@npm:^1.8.1": + version: 1.9.1 + resolution: "loglevel@npm:1.9.1" + checksum: e1c8586108c4d566122e91f8a79c8df728920e3a714875affa5120566761a24077ec8ec9e5fc388b022e39fc411ec6e090cde1b5775871241b045139771eeb06 + languageName: node + linkType: hard + "long@npm:^5.0.0, long@npm:^5.2.1": version: 5.2.3 resolution: "long@npm:5.2.3" @@ -26413,6 +26460,13 @@ __metadata: languageName: node linkType: hard +"pako@npm:^2.0.4": + version: 2.1.0 + resolution: "pako@npm:2.1.0" + checksum: 71666548644c9a4d056bcaba849ca6fd7242c6cf1af0646d3346f3079a1c7f4a66ffec6f7369ee0dc88f61926c10d6ab05da3e1fca44b83551839e89edd75a3e + languageName: node + linkType: hard + "param-case@npm:^3.0.4": version: 3.0.4 resolution: "param-case@npm:3.0.4" @@ -28350,7 +28404,7 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^18.0.0, react-is@npm:^18.2.0": +"react-is@npm:^18.0.0, react-is@npm:^18.2.0, react-is@npm:^18.3.1": version: 18.3.1 resolution: "react-is@npm:18.3.1" checksum: e20fe84c86ff172fc8d898251b7cc2c43645d108bf96d0b8edf39b98f9a2cae97b40520ee7ed8ee0085ccc94736c4886294456033304151c3f94978cec03df21 @@ -32645,7 +32699,7 @@ __metadata: languageName: node linkType: hard -"url-parse@npm:^1.5.10, url-parse@npm:^1.5.3": +"url-parse@npm:^1.4.3, url-parse@npm:^1.5.10, url-parse@npm:^1.5.3": version: 1.5.10 resolution: "url-parse@npm:1.5.10" dependencies: @@ -32802,6 +32856,15 @@ __metadata: languageName: node linkType: hard +"uuid@npm:10.0.0": + version: 10.0.0 + resolution: "uuid@npm:10.0.0" + bin: + uuid: dist/bin/uuid + checksum: 4b81611ade2885d2313ddd8dc865d93d8dccc13ddf901745edca8f86d99bc46d7a330d678e7532e7ebf93ce616679fb19b2e3568873ac0c14c999032acb25869 + languageName: node + linkType: hard + "uuid@npm:^3.3.2, uuid@npm:^3.4.0": version: 3.4.0 resolution: "uuid@npm:3.4.0"