├── .eslintrc.js ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── enhancement.md └── workflows │ ├── catalog.yml │ ├── nr1_lib_deprecations.yml │ ├── pr.yml │ ├── release.yml │ └── repolinter.yml ├── .gitignore ├── .prettierrc.js ├── .releaserc ├── .snyk ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── THIRD_PARTY_NOTICES.md ├── backend ├── README.md ├── api │ ├── constants.js │ ├── index.js │ └── optimize │ │ └── index.js ├── package-lock.json ├── package.json ├── processor │ ├── constants.js │ ├── entities │ │ ├── AWSALB.js │ │ ├── AWSAPIGATEWAYAPI.js │ │ ├── AWSELASTICACHEREDISNODE.js │ │ ├── AWSELASTICSEARCHNODE.js │ │ ├── AWSELB.js │ │ ├── AWSLAMBDAFUNCTION.js │ │ ├── AWSRDSDBINSTANCE.js │ │ ├── AWSSNSTOPIC.js │ │ ├── AWSSQSQUEUE.js │ │ ├── HOST.js │ │ └── utils.js │ ├── index.js │ ├── queries.js │ └── utils │ │ ├── AWS_HOST.js │ │ └── calculate.js └── serverless.yml ├── catalog ├── config.json ├── documentation.md └── screenshots │ ├── .gitkeep │ ├── nr1-cloud-optimize-1.png │ ├── nr1-cloud-optimize-2.png │ └── nr1-cloud-optimize-3.png ├── cla.md ├── docs └── CHANGELOG.md ├── examples └── .gitkeep ├── icon.png ├── launchers └── cloud-optimize-launcher │ ├── catalog │ └── screenshots │ │ └── nr1-cloud-optimize-launcher.png │ ├── icon.png │ └── nr1.json ├── messages.json ├── nerdlets ├── optimization-configuration │ ├── index.js │ ├── nr1.json │ ├── options.js │ └── styles.scss ├── optimizer-beta │ ├── components │ │ ├── collectionCreate │ │ │ └── index.js │ │ ├── collectionEdit │ │ │ ├── edit.js │ │ │ └── index.js │ │ ├── collectionView │ │ │ ├── card │ │ │ │ └── index.js │ │ │ ├── list │ │ │ │ └── index.js │ │ │ └── menuBar │ │ │ │ └── index.js │ │ ├── helpModal │ │ │ ├── help.js │ │ │ └── index.js │ │ ├── jobHistory │ │ │ ├── history.js │ │ │ ├── historyOld.js │ │ │ └── index.js │ │ ├── messages │ │ │ └── index.js │ │ ├── optimizer.js │ │ ├── quickStart │ │ │ └── index.js │ │ └── settings │ │ │ └── modal.js │ ├── context │ │ ├── data.js │ │ └── queries.js │ ├── images │ │ ├── histOption.png │ │ └── runOption.png │ ├── index.js │ ├── nr1.json │ └── styles.scss ├── optimizer │ ├── components │ │ ├── collectionCreate │ │ │ └── index.js │ │ ├── collectionEdit │ │ │ ├── edit.js │ │ │ └── index.js │ │ ├── collectionList │ │ │ └── index.js │ │ ├── jobHistory │ │ │ ├── history.js │ │ │ └── index.js │ │ ├── messages │ │ │ └── index.js │ │ ├── optimizer.js │ │ ├── quickStart │ │ │ └── index.js │ │ └── settings │ │ │ └── modal.js │ ├── context │ │ ├── data.js │ │ └── queries.js │ ├── images │ │ ├── histOption.png │ │ └── runOption.png │ ├── index.js │ ├── nr1.json │ └── styles.scss ├── results-beta │ ├── components │ │ ├── cardView │ │ │ └── index.js │ │ ├── costBar │ │ │ └── index.js │ │ ├── entitySideBar │ │ │ └── index.js │ │ ├── entityView │ │ │ ├── AWSALB.js │ │ │ ├── AWSAPIGATEWAYAPI.js │ │ │ ├── AWSELASTICACHEREDISNODE.js │ │ │ ├── AWSELASTICACHEREDISNODE_STANDARD.js │ │ │ ├── AWSELASTICSEARCHNODE.js │ │ │ ├── AWSELASTICSEARCHNODE_STANDARD.js │ │ │ ├── AWSELB.js │ │ │ ├── AWSLAMBDAFUNCTION.js │ │ │ ├── AWSRDSDBINSTANCE.js │ │ │ ├── AWSSQSQUEUE.js │ │ │ ├── HOST STANDARD.js │ │ │ ├── HOST.js │ │ │ ├── HOSTECSCONTAINERMODAL.js │ │ │ ├── HOSTK8SCONTAINERMODAL.js │ │ │ ├── HOST_ECS.js │ │ │ ├── HOST_ECS_STANDARD.js │ │ │ ├── HOST_K8S.js │ │ │ ├── HOST_K8S_STANDARD.js │ │ │ └── index.js │ │ ├── resultPanel │ │ │ ├── costSummary.js │ │ │ ├── index.js │ │ │ └── suggestionsView.js │ │ ├── results.js │ │ ├── tags │ │ │ ├── bar.js │ │ │ └── modal.js │ │ └── workloadView │ │ │ └── index.js │ ├── context │ │ ├── calculate.js │ │ ├── data.js │ │ ├── provideSuggestions.js │ │ └── queries.js │ ├── index.js │ ├── nr1.json │ └── styles.scss ├── results │ ├── components │ │ ├── entityView │ │ │ ├── AWSALB.js │ │ │ ├── AWSAPIGATEWAYAPI.js │ │ │ ├── AWSELASTICACHEREDISNODE.js │ │ │ ├── AWSELASTICACHEREDISNODE_STANDARD.js │ │ │ ├── AWSELASTICSEARCHNODE.js │ │ │ ├── AWSELASTICSEARCHNODE_STANDARD.js │ │ │ ├── AWSELB.js │ │ │ ├── AWSLAMBDAFUNCTION.js │ │ │ ├── AWSRDSDBINSTANCE.js │ │ │ ├── AWSSQSQUEUE.js │ │ │ ├── HOST STANDARD.js │ │ │ ├── HOST.js │ │ │ ├── HOSTECSCONTAINERMODAL.js │ │ │ ├── HOSTK8SCONTAINERMODAL.js │ │ │ ├── HOST_ECS.js │ │ │ ├── HOST_ECS_STANDARD.js │ │ │ ├── HOST_K8S.js │ │ │ ├── HOST_K8S_STANDARD.js │ │ │ └── index.js │ │ ├── resultPanel │ │ │ ├── costSummary.js │ │ │ ├── index.js │ │ │ └── suggestionsView.js │ │ ├── results.js │ │ ├── tags │ │ │ ├── bar.js │ │ │ └── modal.js │ │ └── workloadView │ │ │ └── index.js │ ├── context │ │ ├── calculate.js │ │ ├── data.js │ │ ├── provideSuggestions.js │ │ └── queries.js │ ├── index.js │ ├── nr1.json │ └── styles.scss ├── shared │ ├── components │ │ ├── adminBar │ │ │ └── index.js │ │ ├── config.js │ │ ├── loader │ │ │ ├── cssLoader.js │ │ │ └── index.js │ │ └── pricingSelector.js │ ├── images │ │ ├── alibabaIcon.png │ │ ├── awsIcon.png │ │ ├── awsIcon2.png │ │ ├── azureIcon.png │ │ ├── googleIcon.png │ │ ├── vmwareIcon.png │ │ └── workloads.png │ ├── lib │ │ ├── processor.js │ │ ├── queries.js │ │ └── utils.js │ ├── ui │ │ └── index.js │ └── utils.js └── suggestions-configuration │ ├── index.js │ ├── nr1.json │ ├── options.js │ └── styles.scss ├── nr-labs-components ├── NRLabsFilterBar │ ├── close.svg │ ├── conjunction.js │ ├── equal.svg │ ├── filter.svg │ ├── index.js │ ├── label.js │ ├── not-equal.svg │ ├── open.svg │ ├── remove.svg │ ├── search.svg │ ├── styles.scss │ └── value.js └── index.js ├── nr1.json ├── package-lock.json ├── package.json ├── pricing-pollers └── azure │ ├── package-lock.json │ ├── package.json │ ├── serverless.yml │ ├── utils.js │ └── vm.js ├── screenshots ├── .gitkeep ├── screenshot_01.png ├── screenshot_02.png └── screenshot_03.png └── third_party_manifest.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es6: true 5 | }, 6 | extends: [ 7 | 'plugin:@newrelic/eslint-plugin-newrelic/react', 8 | 'plugin:@newrelic/eslint-plugin-newrelic/jest', 9 | 'plugin:@newrelic/eslint-plugin-newrelic/prettier' 10 | ], 11 | globals: { 12 | Atomics: 'readonly', 13 | SharedArrayBuffer: 'readonly' 14 | }, 15 | parser: 'babel-eslint', 16 | parserOptions: { 17 | ecmaFeatures: { 18 | jsx: true 19 | }, 20 | ecmaVersion: 2018, 21 | sourceType: 'module' 22 | }, 23 | plugins: ['react', 'prettier'], 24 | rules: { 25 | 'prettier/prettier': 'error', 26 | 'react/prop-types': 0, 27 | 'eslint-comments/no-unused-disable': 0, 28 | 'eslint-comments/no-unlimited-disable': 0 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Describe a scenario in which this project behaves unexpectedly 4 | title: '' 5 | labels: bug, needs-triage 6 | assignees: '' 7 | 8 | --- 9 | 10 | [NOTE]: # ( ^^ Provide a general summary of the issue in the title above. ^^ ) 11 | 12 | ## Description 13 | 14 | [NOTE]: # ( Describe the problem you're encountering. ) 15 | [TIP]: # ( Do NOT give us access or passwords to your New Relic account or API keys! ) 16 | 17 | ## Steps to Reproduce 18 | 19 | [NOTE]: # ( Please be as specific as possible. ) 20 | 21 | ## Expected Behaviour 22 | 23 | [NOTE]: # ( Tell us what you expected to happen. ) 24 | 25 | ## Relevant Logs / Console output 26 | 27 | [NOTE]: # ( Please provide specifics of the local error logs, Browser Dev Tools console, etc. if appropriate and possible. ) 28 | 29 | ## Your Environment 30 | 31 | [TIP]: # ( Include as many relevant details about your environment as possible. ) 32 | 33 | * NR1 CLI version used: 34 | * Browser name and version: 35 | * Operating System and version: 36 | 37 | ## Additional context 38 | 39 | [TIP]: # ( Add any other context about the problem here. ) 40 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/enhancement.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Enhancement request 3 | about: Suggest an idea for a future version of this project 4 | title: '' 5 | labels: enhancement, needs-triage 6 | assignees: '' 7 | 8 | --- 9 | 10 | [NOTE]: # ( ^^ Provide a general summary of the request in the title above. ^^ ) 11 | 12 | ## Summary 13 | 14 | [NOTE]: # ( Provide a brief overview of what the new feature is all about. ) 15 | 16 | ## Desired Behaviour 17 | 18 | [NOTE]: # ( Tell us how the new feature should work. Be specific. ) 19 | [TIP]: # ( Do NOT give us access or passwords to your New Relic account or API keys! ) 20 | 21 | ## Possible Solution 22 | 23 | [NOTE]: # ( Not required. Suggest how to implement the addition or change. ) 24 | 25 | ## Additional context 26 | 27 | [TIP]: # ( Why does this feature matter to you? What unique circumstances do you have? ) 28 | -------------------------------------------------------------------------------- /.github/workflows/catalog.yml: -------------------------------------------------------------------------------- 1 | name: Catalog 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | appName: 7 | description: "NR1 Nerdpack Name" 8 | required: true 9 | version: 10 | description: "Version to update" 11 | required: true 12 | ref: 13 | description: "Commit SHA to update the submodule to" 14 | required: true 15 | user: 16 | description: "User who initiated the deployment" 17 | required: true 18 | action: 19 | description: "Action to take with submodule. Possible values: add, update" 20 | required: true 21 | default: "update" 22 | url: 23 | description: "If action == `add`, must supply URL of repo" 24 | required: false 25 | jobs: 26 | job-check-workflow-dispatch-inputs: 27 | runs-on: ubuntu-latest 28 | steps: 29 | - run: | 30 | echo "appName: ${{ github.event.inputs.appName }}" 31 | echo "version: ${{ github.event.inputs.version }}" 32 | echo "ref: ${{ github.event.inputs.ref }}" 33 | echo "user: ${{ github.event.inputs.user }}" 34 | echo "action: ${{ github.event.inputs.action }}" 35 | echo "url: ${{ github.event.inputs.url }}" 36 | 37 | job-trigger-catalog-workflow: 38 | runs-on: ubuntu-latest 39 | steps: 40 | - name: Invoke nr1-catalog PR workflow 41 | uses: benc-uk/workflow-dispatch@v1 42 | with: 43 | workflow: Generate Catalog PR 44 | repo: newrelic/nr1-catalog 45 | token: ${{ secrets.OPENSOURCE_BOT_TOKEN }} 46 | ref: master 47 | inputs: '{ "appName": "${{ github.event.inputs.appName }}", "version": "${{ github.event.inputs.version }}", "ref": "${{ github.event.inputs.ref }}", "user": "${{ github.event.inputs.user }}", "action": "${{ github.event.inputs.action }}", "url": "${{ github.event.inputs.url }}" }' 48 | -------------------------------------------------------------------------------- /.github/workflows/nr1_lib_deprecations.yml: -------------------------------------------------------------------------------- 1 | name: NR1 Library Deprecation Checks 2 | 3 | on: [push, workflow_dispatch] 4 | 5 | jobs: 6 | nr1_lib_deprecations: 7 | name: Run NR1 Library Deprecation Checks 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Test Default Branch 11 | id: default-branch 12 | uses: actions/github-script@v2 13 | with: 14 | script: | 15 | const data = await github.repos.get(context.repo) 16 | return data.data && data.data.default_branch === context.ref.split('/').slice(-1)[0] 17 | - name: Checkout Self 18 | if: ${{ steps.default-branch.outputs.result == 'true' }} 19 | uses: actions/checkout@v2 20 | - name: Run Repolinter 21 | if: ${{ steps.default-branch.outputs.result == 'true' }} 22 | uses: newrelic/repolinter-action@v1 23 | with: 24 | output_name: 'NR1 library deprecation issues' 25 | label_name: 'nr1-deprecations' 26 | label_color: '800000' 27 | # FIXME: Replace with the appropriate ruleset URL 28 | config_url: https://raw.githubusercontent.com/newrelic/.github/main/repolinter-rulesets/nr1-lib-deprecations.yml 29 | output_type: issue 30 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: Pull Request 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | repository_dispatch: 9 | types: [pull-request] 10 | 11 | jobs: 12 | checkout-and-build-pr: 13 | name: checkout-and-build-pr 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout repo 17 | uses: actions/checkout@v2 18 | 19 | - name: Setup node 20 | uses: actions/setup-node@v1 21 | with: 22 | node-version: 10.x 23 | 24 | - name: Cache node_modules 25 | id: cache-node-modules 26 | uses: actions/cache@v1 27 | env: 28 | cache-name: node-modules 29 | with: 30 | path: ~/.npm 31 | key: ${{ runner.os }}-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} 32 | restore-keys: | 33 | ${{ runner.os }}-${{ env.cache-name }}- 34 | 35 | - name: Install Dependencies 36 | run: npm ci 37 | 38 | - name: Install NR1 CLI 39 | run: curl -s https://cli.nr-ext.net/installer.sh | sudo bash 40 | 41 | - name: NR1 Nerdpack Build 42 | run: | 43 | nr1 nerdpack:build 44 | 45 | eslint: 46 | name: eslint 47 | needs: checkout-and-build-pr 48 | runs-on: ubuntu-latest 49 | steps: 50 | - name: Checkout repo 51 | uses: actions/checkout@v2 52 | 53 | - name: Setup node 54 | uses: actions/setup-node@v1 55 | with: 56 | node-version: 10.x 57 | 58 | - name: Cache node_modules 59 | id: cache-node-modules 60 | uses: actions/cache@v1 61 | env: 62 | cache-name: node-modules 63 | with: 64 | path: ~/.npm 65 | key: ${{ runner.os }}-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} 66 | restore-keys: | 67 | ${{ runner.os }}-${{ env.cache-name }}- 68 | 69 | - name: Install Dependencies 70 | run: npm ci 71 | 72 | - name: Run eslint-check and generate report 73 | id: eslint-check 74 | run: | 75 | npm run eslint-check -- --output-file eslint_report.json --format json 76 | continue-on-error: true 77 | 78 | - name: Annotate Lint Results 79 | uses: ataylorme/eslint-annotate-action@1.0.4 80 | with: 81 | repo-token: ${{ secrets.GITHUB_TOKEN }} 82 | report-json: eslint_report.json 83 | continue-on-error: true 84 | 85 | - name: Check eslint-check outcome 86 | if: steps.eslint-check.outcome != 'success' 87 | run: | 88 | echo "::error::eslint-check failed. View output of _Run eslint-check and generate report_ step" 89 | exit 1 90 | 91 | test: 92 | name: test 93 | needs: checkout-and-build-pr 94 | runs-on: ubuntu-latest 95 | steps: 96 | - name: Checkout repo 97 | uses: actions/checkout@v2 98 | 99 | - name: Setup node 100 | uses: actions/setup-node@v1 101 | with: 102 | node-version: 10.x 103 | 104 | - name: Cache node_modules 105 | id: cache-node-modules 106 | uses: actions/cache@v1 107 | env: 108 | cache-name: node-modules 109 | with: 110 | path: ~/.npm 111 | key: ${{ runner.os }}-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} 112 | restore-keys: | 113 | ${{ runner.os }}-${{ env.cache-name }}- 114 | 115 | - name: Install Dependencies 116 | run: npm ci 117 | 118 | - name: Run npm test 119 | run: npm test 120 | 121 | validate-nerdpack: 122 | name: validate nerdpack 123 | needs: checkout-and-build-pr 124 | runs-on: ubuntu-latest 125 | steps: 126 | - name: Checkout repo 127 | uses: actions/checkout@v2 128 | 129 | - name: Validate Open Source Files 130 | uses: newrelic/validate-nerdpack-action@v1 131 | 132 | - name: Install NR1 CLI 133 | run: | 134 | curl -s https://cli.nr-ext.net/installer.sh | sudo bash 135 | 136 | - name: Validate Nerdpack Schema 137 | run: | 138 | nr1 nerdpack:validate 139 | -------------------------------------------------------------------------------- /.github/workflows/repolinter.yml: -------------------------------------------------------------------------------- 1 | name: Repolinter Action 2 | 3 | # NOTE: This workflow will ONLY check the default branch! 4 | # Currently there is no elegant way to specify the default 5 | # branch in the event filtering, so branches are instead 6 | # filtered in the "Test Default Branch" step. 7 | on: [push, workflow_dispatch] 8 | 9 | jobs: 10 | repolint: 11 | name: Run Repolinter 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Test Default Branch 15 | id: default-branch 16 | uses: actions/github-script@v2 17 | with: 18 | script: | 19 | const data = await github.repos.get(context.repo) 20 | return data.data && data.data.default_branch === context.ref.split('/').slice(-1)[0] 21 | - name: Checkout Self 22 | if: ${{ steps.default-branch.outputs.result == 'true' }} 23 | uses: actions/checkout@v2 24 | - name: Run Repolinter 25 | if: ${{ steps.default-branch.outputs.result == 'true' }} 26 | uses: newrelic/repolinter-action@v1 27 | with: 28 | config_url: https://raw.githubusercontent.com/newrelic/.github/main/repolinter-rulesets/new-relic-one-catalog-project.json 29 | output_type: issue 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules* 2 | tmp* 3 | *.DS_Store 4 | dist* 5 | old* 6 | .serverless* 7 | backend/.serverless* 8 | nerdlets-beta/* -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | trailingComma: 'none', 4 | }; 5 | -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "branches": ["main"], 3 | "plugins": [ 4 | "@semantic-release/commit-analyzer", 5 | "@semantic-release/release-notes-generator", 6 | ["@semantic-release/changelog", { 7 | "changelogFile": "docs/CHANGELOG.md" 8 | }], 9 | "@semantic-release/github", 10 | ["@semantic-release/npm", { 11 | "npmPublish": false 12 | }], 13 | ["@semantic-release/git", { 14 | "assets": ["docs", "package.json", "package-lock.json"], 15 | "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" 16 | }] 17 | ], 18 | "dryRun": false, 19 | "debug": true 20 | } 21 | -------------------------------------------------------------------------------- /.snyk: -------------------------------------------------------------------------------- 1 | # Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. 2 | version: v1.13.5 3 | # ignores vulnerabilities until expiry date; change duration by modifying expiry date 4 | ignore: 5 | SNYK-JS-LODASH-567746: 6 | - lodash: 7 | reason: None given 8 | expires: '2020-06-18T19:11:21.953Z' 9 | - semantic-ui-react > lodash: 10 | reason: None given 11 | expires: '2020-06-18T19:11:21.953Z' 12 | patch: {} 13 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are always welcome. Before contributing please read the 4 | [code of conduct](./CODE_OF_CONDUCT.md) and [search the issue tracker](issues); your issue may have already been discussed or fixed in `main`. To contribute, 5 | [fork](https://help.github.com/articles/fork-a-repo/) this repository, commit your changes, and [send a Pull Request](https://help.github.com/articles/using-pull-requests/). 6 | 7 | Note that our [code of conduct](./CODE_OF_CONDUCT.md) applies to all platforms and venues related to this project; please follow it in all your interactions with the project and its participants. 8 | 9 | ## Feature Requests 10 | 11 | Feature requests should be submitted in the [Issue tracker](../../issues), with a description of the expected behavior & use case, where they’ll remain closed until sufficient interest, [e.g. :+1: reactions](https://help.github.com/articles/about-discussions-in-issues-and-pull-requests/), has been [shown by the community](../../issues?q=label%3A%22votes+needed%22+sort%3Areactions-%2B1-desc). 12 | Before submitting an Issue, please search for similar ones in the 13 | [closed issues](../../issues?q=is%3Aissue+is%3Aclosed+label%3Aenhancement). 14 | 15 | ## Pull Requests 16 | 17 | 1. Ensure any install or build dependencies are removed before the end of the layer when doing a build. 18 | 2. Increase the version numbers in any examples files and the README.md to the new version that this Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/). 19 | 3. You may merge the Pull Request in once you have the sign-off of two other developers, or if you do not have permission to do that, you may request the second reviewer to merge it for you. 20 | 21 | ## Contributor License Agreement 22 | 23 | Keep in mind that when you submit your Pull Request, you'll need to sign the CLA via the click-through using CLA-Assistant. If you'd like to execute our corporate CLA, or if you have any questions, please drop us an email at opensource@newrelic.com. 24 | 25 | For more information about CLAs, please check out Alex Russell’s excellent post, 26 | [“Why Do I Need to Sign This?”](https://infrequently.org/2008/06/why-do-i-need-to-sign-this/). 27 | 28 | ## Slack 29 | 30 | For contributors and maintainers of open source projects hosted by New Relic, we host a public Slack with a channel dedicated to this project. If you are contributing to this project, you're welcome to request access to that community space. 31 | -------------------------------------------------------------------------------- /backend/README.md: -------------------------------------------------------------------------------- 1 | # Cloud Optimize Backend 2 | 3 | This lambda contains the backend code that processes entities with their associated cost and potential instance resizing. 4 | 5 | ## Requesting and optimization job 6 | - POST request is made to: 7 | - DEV: https://wh7l38u7v7.execute-api.us-east-1.amazonaws.com/dev/optimize 8 | - PROD: https://wh7l38u7v7.execute-api.us-east-1.amazonaws.com/devoptimize 9 | - Headers 10 | - `nr-api-key` with the value of a NerdGraph User Key that has access to the apprioriate workloads/entities 11 | - This is required to query entities and write results to nerdstorage 12 | - Payload 13 | - An array of workloadGuids should be supplied, the entities within each workload will be inspected 14 | - accountId key is required to write the results into nerd storage 15 | - The nerdpackUUID (inside nr1.json, or global catalog), this is also a requirement to write results into nerdstorage 16 | - collectionId (uuid) should be supplied to identify the collection the results will be written into, this is generated by the requester 17 | #### Example payload 18 | ```json 19 | { 20 | "workloadGuids": [ 21 | "MTYwNjg2MnxOUjF8V09SS0xPQUR8NDkyMjg", 22 | "MTYwNjg2MnxOUjF8V09SS0xPQUR8MzUxMzc", 23 | "MTYwNjg2MnxOUjF8V09SS0xPQUR8MzUxMzk" 24 | ], 25 | "accountId": 1606862, 26 | "nerdpackUUID": "7e874b6e-c010-4933-a15d-ea5b2e54d029", 27 | "collectionId": "5a6aaefe-7299-4f5f-b33a-bf2fe7c2046d", 28 | "config": {} 29 | } 30 | ``` 31 | - When the POST request is made, you should immediately be returned a payload with a jobId 32 | - You can use this jobId to track when your specific results are completed are returned when querying NerdStorage, or just poll the collection. 33 | - When the job is sent, it is sent to another lambda to process which can take up to 15m max, anything longer than this can be considered a failure 34 | 35 | ## Accessing results 36 | - To access the jobStatus, within your nerdpack use the AccountStorageQuery component and query the account you selected and the `jobStatus` collection 37 | - Example fetchJobStatus function in nerdlets/results/context/data 38 | 39 | - To access the results per workload you can use the EntityStorageQuery component to query the workload entities, and the `optimizerResults` collection 40 | - Results can be sharded across multiple documents so you will need to check 41 | - Example fetchWorkloads function in nerdlets/results/context/data 42 | 43 | -------------------------------------------------------------------------------- /backend/api/constants.js: -------------------------------------------------------------------------------- 1 | exports.NERDGRAPH_URL = { 2 | US: 'https://api.newrelic.com/graphql', 3 | EU: 'https://api.eu.newrelic.com/graphql' 4 | }; 5 | 6 | exports.BASE_HEADERS = { 7 | 'Access-Control-Allow-Headers': '*', 8 | 'Access-Control-Allow-Origin': '*', 9 | 'Access-Control-Allow-Methods': '*', 10 | 'Access-Control-Allow-Credentials': true 11 | }; 12 | -------------------------------------------------------------------------------- /backend/api/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable require-atomic-updates */ 2 | // https://github.com/node-fetch/node-fetch#commonjs 3 | const fetch = (...args) => 4 | import('node-fetch').then(({ default: fetch }) => fetch(...args)); 5 | 6 | const pathHandlers = { 7 | optimize: { POST: require('./optimize') } 8 | }; 9 | 10 | const { NERDGRAPH_URL, BASE_HEADERS } = require('./constants'); 11 | 12 | module.exports.router = async (event, context, callback) => { 13 | const path = 14 | event?.pathParameters?.path || event?.pathParameters?.proxy || '/'; 15 | 16 | const nerdGraphKey = 17 | event.headers?.['NR-API-KEY'] || event.headers?.['nr-api-key']; 18 | 19 | if (!nerdGraphKey) { 20 | const response = { 21 | statusCode: 400, 22 | headers: { 23 | ...BASE_HEADERS 24 | }, 25 | body: JSON.stringify({ 26 | success: false, 27 | message: 'NR-API-Key header missing' 28 | }) 29 | }; 30 | callback(null, response); 31 | } else { 32 | // validate api key 33 | // check whether US or EU since as can't determine from the nerdpack side 34 | const responses = [ 35 | fetch(NERDGRAPH_URL.US, { 36 | method: 'post', 37 | body: JSON.stringify({ 38 | query: 39 | '{\n actor {\n user {\n email\n id\n }\n }\n}\n' 40 | }), 41 | headers: { 42 | 'Content-Type': 'application/json', 43 | 'API-Key': nerdGraphKey 44 | } 45 | }), 46 | fetch(NERDGRAPH_URL.EU, { 47 | method: 'post', 48 | body: JSON.stringify({ 49 | query: 50 | '{\n actor {\n user {\n email\n id\n }\n }\n}\n' 51 | }), 52 | headers: { 53 | 'Content-Type': 'application/json', 54 | 'API-Key': nerdGraphKey 55 | } 56 | }) 57 | ]; 58 | 59 | const responseData = await Promise.allSettled(responses); 60 | const responseValues = await Promise.allSettled( 61 | responseData.map(d => d.value.json()) 62 | ); 63 | 64 | let user = null; 65 | 66 | if (responseValues[0].status === 'fulfilled') { 67 | // US REGION 68 | event.headers['NR-REGION'] = 'US'; 69 | event.headers['nr-region'] = 'US'; 70 | user = responseValues[0]?.value?.data?.actor?.user || null; 71 | } else if (responseValues[1].status === 'fulfilled') { 72 | // EU Region 73 | event.headers['NR-REGION'] = 'EU'; 74 | event.headers['nr-region'] = 'EU'; 75 | user = responseValues[1]?.value?.data?.actor?.user || null; 76 | } else { 77 | // unable to determine region or validate token 78 | const response = { 79 | statusCode: 403, 80 | headers: { 81 | ...BASE_HEADERS 82 | }, 83 | body: JSON.stringify({ 84 | success: false, 85 | message: 'unable to determine region or validate token' 86 | }) 87 | }; 88 | callback(null, response); 89 | } 90 | 91 | if (!user) { 92 | const response = { 93 | statusCode: 403, 94 | headers: { 95 | ...BASE_HEADERS 96 | }, 97 | body: JSON.stringify({ 98 | success: false, 99 | message: 'invalid api key', 100 | response: user 101 | }) 102 | }; 103 | callback(null, response); 104 | } 105 | } 106 | 107 | if (path in pathHandlers && event?.httpMethod in pathHandlers[path]) { 108 | try { 109 | const body = JSON.parse(event.body); 110 | return pathHandlers[path][event.httpMethod]( 111 | { 112 | body, 113 | headers: event.headers, 114 | key: nerdGraphKey 115 | }, 116 | context, 117 | callback 118 | ); 119 | } catch (e) { 120 | const response = { 121 | statusCode: 400, 122 | headers: { 123 | ...BASE_HEADERS 124 | }, 125 | body: JSON.stringify({ 126 | success: false, 127 | message: 'An error occurred', 128 | err: e, 129 | pathParams: event.pathParameters, 130 | queryParams: event.queryStringParameters, 131 | body: event.body 132 | }) 133 | }; 134 | callback(null, response); 135 | } 136 | } else { 137 | const response = { 138 | statusCode: 405, 139 | headers: { 140 | ...BASE_HEADERS 141 | }, 142 | body: JSON.stringify({ 143 | success: false, 144 | message: `Invalid HTTP Method: ${event.httpMethod}`, 145 | pathParams: event.pathParameters, 146 | queryParams: event.queryStringParameters, 147 | body: event.body 148 | }) 149 | }; 150 | 151 | callback(null, response); 152 | } 153 | }; 154 | -------------------------------------------------------------------------------- /backend/api/optimize/index.js: -------------------------------------------------------------------------------- 1 | const { v4: uuidv4, validate } = require('uuid'); 2 | const AWS = require('aws-sdk'); 3 | // eslint-disable-line import/no-extraneous-dependencies 4 | const lambda = new AWS.Lambda(); 5 | 6 | const { NERDGRAPH_URL, BASE_HEADERS } = require('../constants'); 7 | 8 | module.exports = (event, context, cb) => { 9 | const { body, headers } = event; 10 | const nerdGraphUrl = NERDGRAPH_URL[headers?.['NR-REGION'] || 'US']; 11 | 12 | const errors = []; 13 | 14 | if ( 15 | !body.workloadGuids || 16 | !Array.isArray(body.workloadGuids) || 17 | !(body?.workloadGuids || []).length > 0 18 | ) { 19 | errors.push('array of workloadGuids missing'); 20 | } 21 | 22 | if (!body.nerdpackUUID) { 23 | errors.push('nerdpackUUID missing'); 24 | } else if (!validate(body.nerdpackUUID)) { 25 | errors.push('nerdpackUUID invalid'); 26 | } 27 | 28 | if (!body.accountId) { 29 | errors.push('accountId missing'); 30 | } else if (isNaN(body.accountId)) { 31 | errors.push('accountId should be an integer'); 32 | } 33 | 34 | if (errors.length > 0) { 35 | cb(null, { 36 | statusCode: 400, 37 | headers: { ...BASE_HEADERS }, 38 | body: JSON.stringify({ 39 | success: false, 40 | errors, 41 | msg: 'failed' 42 | }) 43 | }); 44 | } else { 45 | const jobId = uuidv4(); 46 | event.jobId = jobId; 47 | event.nerdGraphUrl = nerdGraphUrl; 48 | 49 | const params = { 50 | FunctionName: `optimizer-${process.env.STAGE}-optimize-processor`, 51 | ClientContext: 'STRING_VALUE', 52 | InvocationType: 'Event', 53 | LogType: 'None', 54 | Payload: JSON.stringify(event) 55 | }; 56 | 57 | lambda.invoke(params, function(err, data) { 58 | // eslint-disable-next-line no-console 59 | if (err) console.log(err, err.stack); 60 | // eslint-disable-next-line no-console 61 | else console.log(data); 62 | }); 63 | 64 | cb(null, { 65 | statusCode: 200, 66 | headers: { ...BASE_HEADERS }, 67 | body: JSON.stringify({ 68 | success: true, 69 | jobId, 70 | msg: 'optimization job sent' 71 | }) 72 | }); 73 | } 74 | }; 75 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "optimizer", 3 | "path": "hook", 4 | "version": "1.0.0", 5 | "description": "Optimizer backend", 6 | "main": "index.js", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "async": "^3.2.3", 14 | "graphql-tag": "^2.12.6", 15 | "lodash": "^4.17.21", 16 | "node-fetch": "^3.2.1", 17 | "pino": "^7.8.0", 18 | "uuid": "^8.3.2" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /backend/processor/constants.js: -------------------------------------------------------------------------------- 1 | exports.NERDGRAPH_URL = { 2 | US: 'https://api.newrelic.com/graphql', 3 | EU: 'https://api.eu.newrelic.com/graphql' 4 | }; 5 | 6 | exports.BASE_HEADERS = { 7 | 'Access-Control-Allow-Headers': '*', 8 | 'Access-Control-Allow-Origin': '*', 9 | 'Access-Control-Allow-Methods': '*', 10 | 'Access-Control-Allow-Credentials': true 11 | }; 12 | 13 | exports.AWS_LOCATIONS_URL = 14 | 'https://b0.p.awsstatic.com/locations/1.0/aws/current/locations.json'; 15 | -------------------------------------------------------------------------------- /backend/processor/entities/AWSALB.js: -------------------------------------------------------------------------------- 1 | const { batchEntityQuery, fetchPricing, BASE_URL } = require('./utils'); 2 | 3 | const LoadBalancerQuery = `FROM LoadBalancerSample SELECT latest(awsRegion), latest(provider.ruleEvaluations.Sum), latest(provider.processedBytes.Maximum), latest(provider.newConnectionCount.Sum), latest(procider.activeConnectionCount.Sum) LIMIT 1`; 4 | 5 | exports.run = ( 6 | entities, 7 | key, 8 | config, 9 | timeNrql, 10 | totalPeriodMs, 11 | nerdGraphUrl 12 | ) => { 13 | // milliseconds to hours - divide the time value by 3.6e+6 14 | const operatingHours = totalPeriodMs / 3.6e6; 15 | 16 | return new Promise(resolve => { 17 | const query = `query Query($guids: [EntityGuid]!) { 18 | actor { 19 | entities(guids: $guids) { 20 | reporting 21 | alertSeverity 22 | name 23 | guid 24 | domain 25 | type 26 | entityType 27 | tags { 28 | key 29 | values 30 | } 31 | LoadBalancerSample: nrdbQuery(nrql: "${LoadBalancerQuery} ${timeNrql}", timeout: 120) { 32 | results 33 | } 34 | } 35 | } 36 | }`; 37 | 38 | batchEntityQuery(key, query, entities, config, nerdGraphUrl).then( 39 | async entityData => { 40 | // get cloud pricing 41 | const cloudPricing = await fetchPricing( 42 | `${BASE_URL}/amazon/elb/pricing.json` 43 | ); 44 | const priceData = cloudPricing?.priceData; 45 | 46 | if (priceData) { 47 | entityData.forEach(e => { 48 | // move samples top level 49 | const LoadBalancerSample = 50 | e?.LoadBalancerSample?.results?.[0] || {}; 51 | 52 | // clean up keys 53 | Object.keys(LoadBalancerSample).forEach(key => { 54 | if (!LoadBalancerSample[key]) { 55 | delete LoadBalancerSample[key]; 56 | } else if (key.startsWith('latest.')) { 57 | const newKey = key.replace('latest.', ''); 58 | LoadBalancerSample[newKey] = LoadBalancerSample[key]; 59 | delete LoadBalancerSample[key]; 60 | } 61 | }); 62 | e.LoadBalancerSample = LoadBalancerSample; 63 | 64 | const region = priceData?.mapping?.[e.tags?.['aws.awsRegion']?.[0]]; 65 | const pricing = priceData?.regions?.[region]; 66 | 67 | // https://aws.amazon.com/elasticloadbalancing/pricing/ 68 | 69 | if (pricing) { 70 | // LCU Cost 71 | // You are charged only on the dimension with the highest usage. An LCU contains: 72 | // 25 new connections per second. 73 | // 3,000 active connections per minute. 74 | // 1 GB per hour for EC2 instances, containers and IP addresses as targets and 0.4 GB per hour for Lambda functions as targets 75 | 76 | // work on 1 connection per second 77 | const newConnLCUs = LoadBalancerSample[ 78 | 'provider.newConnectionCount.Sum' 79 | ] 80 | ? 1 / LoadBalancerSample['provider.newConnectionCount.Sum'] 81 | : 0; 82 | 83 | // work on 1 new connection per second, each lasting 2 minutes 84 | const activeConnLCUs = LoadBalancerSample[ 85 | 'provider.activeConnectionCount.Sum' 86 | ] 87 | ? 120 / LoadBalancerSample['provider.activeConnectionCount.Sum'] 88 | : 0; 89 | 90 | const processedGbLCUs = LoadBalancerSample[ 91 | 'provider.processedBytes.Sum' 92 | ] 93 | ? LoadBalancerSample['provider.processedBytes.Sum'] / 1e9 / 1 94 | : 0; 95 | 96 | // Rule Evaluations (per second): For simplicity, assume that all configured rules are processed for a request. 97 | // Each LCU provides 1,000 rule evaluations per second (averaged over the hour). 98 | // Since your application receives 5 requests/sec, 60 processed rules for each request results 99 | // in a maximum 250 rule evaluations per second (60 processed rules – 10 free rules) * 5 or 0.25 LCU (250 rule evaluations per second / 1,000 rule evaluations per second) 100 | const rulesSum = 101 | LoadBalancerSample['provider.ruleEvaluations.Sum']; 102 | const rulesEvalLCUs = rulesSum > 10 ? (rulesSum - 10) * 5 : 0; 103 | 104 | e.lcuPricePerHour = parseFloat( 105 | pricing['Application Load Balancer LCU Hours'].price 106 | ); 107 | 108 | e.lcuCostPerHour = 109 | e.lcuPricePerHour * 110 | (newConnLCUs + 111 | activeConnLCUs + 112 | processedGbLCUs + 113 | rulesEvalLCUs); 114 | e.pricePerHour = parseFloat( 115 | pricing['Application Load Balancer Hours'].price 116 | ); 117 | 118 | e.periodCost = 119 | operatingHours * e.pricePerHour + 120 | operatingHours * e.lcuCostPerHour; 121 | } 122 | }); 123 | } 124 | 125 | resolve(entityData); 126 | } 127 | ); 128 | }); 129 | }; 130 | -------------------------------------------------------------------------------- /backend/processor/entities/AWSAPIGATEWAYAPI.js: -------------------------------------------------------------------------------- 1 | const { batchEntityQuery, fetchPricing, BASE_URL } = require('./utils'); 2 | 3 | const GatewaySampleQuery = `SELECT sum(provider.count.SampleCount) as 'requests' FROM ApiGatewaySample WHERE provider='ApiGatewayApi' LIMIT 1`; 4 | 5 | exports.run = ( 6 | entities, 7 | key, 8 | config, 9 | timeNrql, 10 | totalPeriodMs, 11 | nerdGraphUrl 12 | ) => { 13 | return new Promise(resolve => { 14 | const query = `query Query($guids: [EntityGuid]!) { 15 | actor { 16 | entities(guids: $guids) { 17 | reporting 18 | alertSeverity 19 | name 20 | guid 21 | domain 22 | type 23 | entityType 24 | tags { 25 | key 26 | values 27 | } 28 | ApiGatewaySample: nrdbQuery(nrql: "${GatewaySampleQuery} ${timeNrql}", timeout: 120) { 29 | results 30 | } 31 | } 32 | } 33 | }`; 34 | 35 | batchEntityQuery(key, query, entities, config, nerdGraphUrl).then( 36 | async entityData => { 37 | // get cloud pricing 38 | const cloudPricing = await fetchPricing( 39 | `${BASE_URL}/amazon/apigateway/pricing.json` 40 | ); 41 | const priceData = cloudPricing?.priceData; 42 | 43 | if (priceData) { 44 | // massage entity data 45 | entityData.forEach(e => { 46 | // move samples top level 47 | const ApiGatewaySample = e?.ApiGatewaySample?.results?.[0] || {}; 48 | 49 | // clean up keys 50 | Object.keys(ApiGatewaySample).forEach(key => { 51 | if (!ApiGatewaySample[key]) { 52 | delete ApiGatewaySample[key]; 53 | } else if (key.startsWith('latest.')) { 54 | const newKey = key.replace('latest.', ''); 55 | ApiGatewaySample[newKey] = ApiGatewaySample[key]; 56 | delete ApiGatewaySample[key]; 57 | } 58 | }); 59 | e.ApiGatewaySample = ApiGatewaySample; 60 | 61 | const region = priceData?.mapping?.[e.tags?.['aws.awsRegion']?.[0]]; 62 | const pricing = 63 | priceData?.regions?.[region.replace('Europe', 'EU')]; 64 | 65 | if (pricing) { 66 | const requests = ApiGatewaySample.requests || 0; 67 | const apiCallPrice = 68 | pricing?.['API Calls Number of up to 333 million']?.price; 69 | 70 | if (apiCallPrice) { 71 | e.requestCost = apiCallPrice * requests; 72 | e.apiCallPrice = apiCallPrice; 73 | } 74 | } 75 | }); 76 | } 77 | 78 | resolve(entityData); 79 | } 80 | ); 81 | }); 82 | }; 83 | -------------------------------------------------------------------------------- /backend/processor/entities/AWSELASTICACHEREDISNODE.js: -------------------------------------------------------------------------------- 1 | const { 2 | batchEntityQuery, 3 | fetchPricing, 4 | nrqlQuery, 5 | BASE_URL 6 | } = require('./utils'); 7 | 8 | const NodeQuery = `SELECT max(provider.cacheHits.Maximum), max(provider.cacheMisses.Maximum), max(provider.cpuUtilization.Maximum), max(provider.currConnections.Maximum), max(provider.newConnections.Maximum), max(provider.currItems.Maximum), max(provider.bytesUsedForCache.Maximum) FROM DatastoreSample WHERE provider='ElastiCacheRedisNode' LIMIT 1`; 9 | 10 | const ClusterDataQuery = (clusterId, timeNrql) => 11 | `SELECT latest(provider.cacheNodeType), latest(provider.cacheClusterId), latest(entityName) FROM DatastoreSample WHERE provider='ElastiCacheRedisCluster' AND provider.cacheClusterId = '${clusterId}' LIMIT 1 ${timeNrql}`; 12 | 13 | exports.run = ( 14 | entities, 15 | key, 16 | config, 17 | timeNrql, 18 | totalPeriodMs, 19 | nerdGraphUrl 20 | ) => { 21 | // milliseconds to hours - divide the time value by 3.6e+6 22 | const operatingHours = totalPeriodMs / 3.6e6; 23 | 24 | return new Promise(resolve => { 25 | const query = `query Query($guids: [EntityGuid]!) { 26 | actor { 27 | entities(guids: $guids) { 28 | reporting 29 | alertSeverity 30 | name 31 | guid 32 | domain 33 | type 34 | entityType 35 | tags { 36 | key 37 | values 38 | } 39 | NodeSample: nrdbQuery(nrql: "${NodeQuery} ${timeNrql}", timeout: 120) { 40 | results 41 | } 42 | } 43 | } 44 | }`; 45 | 46 | batchEntityQuery(key, query, entities, config, nerdGraphUrl).then( 47 | async entityData => { 48 | // get cloud pricing 49 | const cloudPricing = await fetchPricing( 50 | `${BASE_URL}/amazon/elasticache/pricing.json` 51 | ); 52 | const priceData = cloudPricing?.priceData; 53 | 54 | if (priceData) { 55 | // cluster data promises 56 | const clusters = []; 57 | 58 | // massage entity data 59 | entityData.forEach(e => { 60 | // move samples top level 61 | const NodeSample = e?.NodeSample?.results?.[0] || {}; 62 | 63 | // clean up keys 64 | Object.keys(NodeSample).forEach(key => { 65 | if (!NodeSample[key]) { 66 | delete NodeSample[key]; 67 | } else if (key.startsWith('latest.')) { 68 | const newKey = key.replace('latest.', ''); 69 | NodeSample[newKey] = NodeSample[key]; 70 | delete NodeSample[key]; 71 | } 72 | }); 73 | 74 | e.NodeSample = NodeSample; 75 | e.cacheClusterId = e.tags?.['aws.cacheClusterId']?.[0]; 76 | 77 | const accountId = e.tags?.accountId?.[0]; 78 | const foundCluster = clusters.find(c => c.id === e.cacheClusterId); 79 | if (!foundCluster) { 80 | clusters.push({ 81 | id: e.cacheClusterId, 82 | accountId: parseInt(accountId) 83 | }); 84 | } 85 | }); 86 | 87 | // get cluster data 88 | const clusterDataPromises = clusters.map(c => 89 | nrqlQuery( 90 | key, 91 | c.accountId, 92 | ClusterDataQuery(c.id, timeNrql), 93 | null, 94 | nerdGraphUrl 95 | ) 96 | ); 97 | 98 | const clusterDataResponses = await Promise.all(clusterDataPromises); 99 | const clusterData = clusterDataResponses 100 | .map(c => c?.results?.[0]) 101 | .filter(c => c); 102 | 103 | // use cluster data to determine node pricing 104 | entityData.forEach(e => { 105 | const cluster = clusterData.find( 106 | r => r['latest.provider.cacheClusterId'] === e.cacheClusterId 107 | ); 108 | 109 | if (cluster) { 110 | const region = priceData?.mapping[e.tags?.['aws.awsRegion']?.[0]]; 111 | const instanceType = cluster['latest.provider.cacheNodeType']; 112 | const pricing = priceData.regions[region.replace('Europe', 'EU')]; 113 | 114 | if (pricing) { 115 | const discoveredPrices = []; 116 | 117 | Object.keys(pricing).forEach(key => { 118 | const data = pricing[key]; 119 | const priceInstanceType = data?.['Instance Type']; 120 | if (priceInstanceType === instanceType) { 121 | discoveredPrices.push(data); 122 | } 123 | }); 124 | 125 | if (discoveredPrices.length > 0) { 126 | e.discoveredPrices = discoveredPrices; 127 | e.periodCost = discoveredPrices?.[0]?.price * operatingHours; 128 | } 129 | } 130 | } 131 | }); 132 | } 133 | 134 | resolve(entityData); 135 | } 136 | ); 137 | }); 138 | }; 139 | -------------------------------------------------------------------------------- /backend/processor/entities/AWSELASTICSEARCHNODE.js: -------------------------------------------------------------------------------- 1 | const { 2 | batchEntityQuery, 3 | fetchPricing, 4 | nrqlQuery, 5 | BASE_URL 6 | } = require('./utils'); 7 | 8 | const NodeQuery = `FROM DatastoreSample SELECT latest(provider.domainName), max(provider.CPUUtilization.Maximum), max(provider.ReadIOPS.Maximum), max(provider.WriteIOPS.Maximum), max(provider.ReadThroughput.Maximum), max(provider.WriteThroughput.Maximum), max(provider.SearchRate.Maximum) WHERE provider='ElasticsearchNode' LIMIT 1`; 9 | 10 | const ClusterDataQuery = (clusterName, timeNrql) => 11 | `FROM DatastoreSample SELECT latest(awsRegion), latest(provider.instanceType), latest(entityName) WHERE provider='ElasticsearchCluster' WHERE entityName = '${clusterName}' OR provider.domainName = '${clusterName}' LIMIT 1 ${timeNrql}`; 12 | 13 | exports.run = ( 14 | entities, 15 | key, 16 | config, 17 | timeNrql, 18 | totalPeriodMs, 19 | nerdGraphUrl 20 | ) => { 21 | // milliseconds to hours - divide the time value by 3.6e+6 22 | const operatingHours = totalPeriodMs / 3.6e6; 23 | 24 | return new Promise(resolve => { 25 | const query = `query Query($guids: [EntityGuid]!) { 26 | actor { 27 | entities(guids: $guids) { 28 | reporting 29 | alertSeverity 30 | name 31 | guid 32 | domain 33 | type 34 | entityType 35 | tags { 36 | key 37 | values 38 | } 39 | NodeSample: nrdbQuery(nrql: "${NodeQuery} ${timeNrql}", timeout: 120) { 40 | results 41 | } 42 | } 43 | } 44 | }`; 45 | 46 | batchEntityQuery(key, query, entities, config, nerdGraphUrl).then( 47 | async entityData => { 48 | // get cloud pricing 49 | const cloudPricing = await fetchPricing( 50 | `${BASE_URL}/amazon/elasticsearch/pricing.json` 51 | ); 52 | const priceData = cloudPricing?.priceData; 53 | 54 | if (priceData) { 55 | // cluster data promises 56 | const clusters = []; 57 | 58 | // massage entity data 59 | entityData.forEach(e => { 60 | // move samples top level 61 | const NodeSample = e?.NodeSample?.results?.[0] || {}; 62 | 63 | // clean up keys 64 | Object.keys(NodeSample).forEach(key => { 65 | if (!NodeSample[key]) { 66 | delete NodeSample[key]; 67 | } else if (key.startsWith('latest.')) { 68 | const newKey = key.replace('latest.', ''); 69 | NodeSample[newKey] = NodeSample[key]; 70 | delete NodeSample[key]; 71 | } 72 | }); 73 | e.NodeSample = NodeSample; 74 | const awsDomainName = e.tags?.['aws.domainName']?.[0]; 75 | const accountId = e.tags?.accountId?.[0]; 76 | e.clusterName = 77 | NodeSample?.['provider.domainName'] || awsDomainName; 78 | 79 | const foundCluster = clusters.find(c => c.name === e.clusterName); 80 | if (!foundCluster) { 81 | clusters.push({ 82 | name: e.clusterName, 83 | accountId: parseInt(accountId) 84 | }); 85 | } 86 | }); 87 | 88 | // get cluster data 89 | const clusterDataPromises = clusters.map(c => 90 | nrqlQuery( 91 | key, 92 | c.accountId, 93 | ClusterDataQuery(c.name, timeNrql), 94 | null, 95 | nerdGraphUrl 96 | ) 97 | ); 98 | const clusterDataResponses = await Promise.all(clusterDataPromises); 99 | const clusterData = clusterDataResponses 100 | .map(c => c?.results?.[0]) 101 | .filter(c => c); 102 | 103 | // use cluster data to determine node pricing 104 | entityData.forEach(e => { 105 | const cluster = clusterData.find( 106 | r => r['latest.entityName'] === e.clusterName 107 | ); 108 | 109 | if (cluster) { 110 | const region = priceData?.mapping[cluster['latest.awsRegion']]; 111 | const instanceType = cluster[ 112 | 'latest.provider.instanceType' 113 | ].replace('elasticsearch', 'search'); 114 | 115 | const pricing = priceData.regions[region.replace('Europe', 'EU')]; 116 | 117 | if (pricing) { 118 | const discoveredPrices = []; 119 | 120 | Object.keys(pricing).forEach(key => { 121 | const data = pricing[key]; 122 | const priceInstanceType = data?.['Instance Type']; 123 | if (priceInstanceType === instanceType) { 124 | discoveredPrices.push(data); 125 | } 126 | }); 127 | 128 | if (discoveredPrices.length > 0) { 129 | e.discoveredPrices = discoveredPrices; 130 | e.periodCost = discoveredPrices?.[0]?.price * operatingHours; 131 | } 132 | } 133 | } 134 | }); 135 | } 136 | 137 | resolve(entityData); 138 | } 139 | ); 140 | }); 141 | }; 142 | -------------------------------------------------------------------------------- /backend/processor/entities/AWSELB.js: -------------------------------------------------------------------------------- 1 | const { batchEntityQuery, fetchPricing, BASE_URL } = require('./utils'); 2 | 3 | const LoadBalancerQuery = `FROM LoadBalancerSample SELECT latest(awsRegion), latest(provider.ruleEvaluations.Sum), latest(provider.estimatedProcessedBytes.Maximum), latest(provider.estimatedAlbActiveConnectionCount.Maximum), latest(provider.estimatedAlbNewConnectionCount.Maximum) LIMIT 1`; 4 | 5 | exports.run = ( 6 | entities, 7 | key, 8 | config, 9 | timeNrql, 10 | totalPeriodMs, 11 | nerdGraphUrl 12 | ) => { 13 | // milliseconds to hours - divide the time value by 3.6e+6 14 | const operatingHours = totalPeriodMs / 3.6e6; 15 | 16 | return new Promise(resolve => { 17 | const query = `query Query($guids: [EntityGuid]!) { 18 | actor { 19 | entities(guids: $guids) { 20 | reporting 21 | alertSeverity 22 | name 23 | guid 24 | domain 25 | type 26 | entityType 27 | tags { 28 | key 29 | values 30 | } 31 | LoadBalancerSample: nrdbQuery(nrql: "${LoadBalancerQuery} ${timeNrql}", timeout: 120) { 32 | results 33 | } 34 | } 35 | } 36 | }`; 37 | 38 | batchEntityQuery(key, query, entities, config, nerdGraphUrl).then( 39 | async entityData => { 40 | // get cloud pricing 41 | const cloudPricing = await fetchPricing( 42 | `${BASE_URL}/amazon/elb/pricing.json` 43 | ); 44 | const priceData = cloudPricing?.priceData; 45 | 46 | if (priceData) { 47 | entityData.forEach(e => { 48 | // move samples top level 49 | const LoadBalancerSample = 50 | e?.LoadBalancerSample?.results?.[0] || {}; 51 | 52 | // clean up keys 53 | Object.keys(LoadBalancerSample).forEach(key => { 54 | if (!LoadBalancerSample[key]) { 55 | delete LoadBalancerSample[key]; 56 | } else if (key.startsWith('latest.')) { 57 | const newKey = key.replace('latest.', ''); 58 | LoadBalancerSample[newKey] = LoadBalancerSample[key]; 59 | delete LoadBalancerSample[key]; 60 | } 61 | }); 62 | e.LoadBalancerSample = LoadBalancerSample; 63 | 64 | const region = priceData?.mapping?.[e.tags?.['aws.awsRegion']?.[0]]; 65 | const pricing = 66 | priceData?.regions?.[region.replace('Europe', 'EU')]; 67 | 68 | // https://aws.amazon.com/elasticloadbalancing/pricing/ 69 | 70 | if (pricing) { 71 | const estimatedProcessedBytes = 72 | LoadBalancerSample['provider.estimatedProcessedBytes.Maximum']; 73 | 74 | e.processedGB = estimatedProcessedBytes / 1e9; 75 | 76 | e.pricePerGB = parseFloat( 77 | pricing['Classic Load Balancer Data'].price 78 | ); 79 | 80 | e.pricePerHour = parseFloat( 81 | pricing['Classic Load Balancer Hours'].price 82 | ); 83 | 84 | e.costPerHour = e.pricePerHour * operatingHours; 85 | e.costPerGB = e.processedGB * e.pricePerGB; 86 | e.periodCost = e.costPerGB + e.costPerHour; 87 | } 88 | }); 89 | } 90 | 91 | resolve(entityData); 92 | } 93 | ); 94 | }); 95 | }; 96 | -------------------------------------------------------------------------------- /backend/processor/entities/AWSLAMBDAFUNCTION.js: -------------------------------------------------------------------------------- 1 | const { batchEntityQuery, fetchPricing, BASE_URL } = require('./utils'); 2 | 3 | const LambdaQuery = `SELECT average(provider.duration.Maximum), sum(provider.invocations.Sum), latest(provider.memorySize) FROM ServerlessSample LIMIT 1`; 4 | 5 | exports.run = ( 6 | entities, 7 | key, 8 | config, 9 | timeNrql, 10 | totalPeriodMs, 11 | nerdGraphUrl 12 | ) => { 13 | return new Promise(resolve => { 14 | const query = `query Query($guids: [EntityGuid]!) { 15 | actor { 16 | entities(guids: $guids) { 17 | reporting 18 | alertSeverity 19 | name 20 | guid 21 | domain 22 | type 23 | entityType 24 | tags { 25 | key 26 | values 27 | } 28 | LambdaSample: nrdbQuery(nrql: "${LambdaQuery} ${timeNrql}", timeout: 120) { 29 | results 30 | } 31 | } 32 | } 33 | }`; 34 | 35 | batchEntityQuery(key, query, entities, config, nerdGraphUrl).then( 36 | async entityData => { 37 | // get cloud pricing 38 | const cloudPricing = await fetchPricing( 39 | `${BASE_URL}/amazon/lambda/pricing.json` 40 | ); 41 | const priceData = cloudPricing?.priceData; 42 | 43 | if (priceData) { 44 | // massage entity data 45 | entityData.forEach(e => { 46 | // move samples top level 47 | const LambdaSample = e?.LambdaSample?.results?.[0] || {}; 48 | 49 | // clean up keys 50 | Object.keys(LambdaSample).forEach(key => { 51 | if (!LambdaSample[key]) { 52 | delete LambdaSample[key]; 53 | } else if (key.startsWith('latest.')) { 54 | const newKey = key.replace('latest.', ''); 55 | LambdaSample[newKey] = LambdaSample[key]; 56 | delete LambdaSample[key]; 57 | } 58 | }); 59 | e.LambdaSample = LambdaSample; 60 | 61 | const region = priceData?.mapping?.[e.tags?.['aws.awsRegion']?.[0]]; 62 | const pricing = 63 | priceData?.regions?.[region.replace('Europe', 'EU')]; 64 | 65 | if (pricing) { 66 | const invocations = LambdaSample['sum.provider.invocations.Sum']; 67 | const averageDurationMs = 68 | LambdaSample['average.provider.duration.Maximum']; 69 | e.durationPrice = pricing?.['Lambda Duration'].price; 70 | e.requestPrice = pricing?.['Lambda Requests'].price; 71 | 72 | if (invocations && averageDurationMs) { 73 | e.durationCost = averageDurationMs * e.durationPrice; 74 | e.requestCost = invocations * e.requestPrice; 75 | } 76 | } 77 | }); 78 | } 79 | 80 | resolve(entityData); 81 | } 82 | ); 83 | }); 84 | }; 85 | -------------------------------------------------------------------------------- /backend/processor/entities/AWSSNSTOPIC.js: -------------------------------------------------------------------------------- 1 | const { batchEntityQuery, fetchPricing, BASE_URL } = require('./utils'); 2 | 3 | const SnsQuery = `SELECT sum(provider.numberOfMessagesPublished.Sum) as 'publishedMessages' FROM QueueSample WHERE provider='SnsTopic' LIMIT 1`; 4 | 5 | exports.run = ( 6 | entities, 7 | key, 8 | config, 9 | timeNrql, 10 | totalPeriodMs, 11 | nerdGraphUrl 12 | ) => { 13 | // const { AWSELASTICSEARCHNODE, defaultCloud, defaultRegions } = config; 14 | 15 | return new Promise(resolve => { 16 | const query = `query Query($guids: [EntityGuid]!) { 17 | actor { 18 | entities(guids: $guids) { 19 | reporting 20 | alertSeverity 21 | name 22 | guid 23 | domain 24 | type 25 | entityType 26 | tags { 27 | key 28 | values 29 | } 30 | QueueSample: nrdbQuery(nrql: "${SnsQuery} ${timeNrql}", timeout: 120) { 31 | results 32 | } 33 | } 34 | } 35 | }`; 36 | 37 | batchEntityQuery(key, query, entities, config, nerdGraphUrl).then( 38 | async entityData => { 39 | // get cloud pricing 40 | const cloudPricing = await fetchPricing( 41 | `${BASE_URL}/amazon/sns/pricing.json` 42 | ); 43 | const priceData = cloudPricing?.priceData; 44 | 45 | if (priceData) { 46 | // massage entity data 47 | entityData.forEach(e => { 48 | // move samples top level 49 | const QueueSample = e?.QueueSample?.results?.[0] || {}; 50 | 51 | // clean up keys 52 | Object.keys(QueueSample).forEach(key => { 53 | if (!QueueSample[key]) { 54 | delete QueueSample[key]; 55 | } else if (key.startsWith('latest.')) { 56 | const newKey = key.replace('latest.', ''); 57 | QueueSample[newKey] = QueueSample[key]; 58 | delete QueueSample[key]; 59 | } 60 | }); 61 | e.QueueSample = QueueSample; 62 | 63 | // incomplete 64 | }); 65 | } 66 | 67 | resolve(entityData); 68 | } 69 | ); 70 | }); 71 | }; 72 | -------------------------------------------------------------------------------- /backend/processor/entities/AWSSQSQUEUE.js: -------------------------------------------------------------------------------- 1 | const { batchEntityQuery, fetchPricing, BASE_URL } = require('./utils'); 2 | 3 | const SqsQuery = `FROM QueueSample SELECT latest(awsRegion), latest(provider.numberOfMessagesSent.Sum + provider.numberOfMessagesReceived.Sum + provider.numberOfMessagesDeleted.Sum) as 'numberOfMessages' WHERE dataSourceName = 'SQS' LIMIT 1`; 4 | 5 | exports.run = ( 6 | entities, 7 | key, 8 | config, 9 | timeNrql, 10 | totalPeriodMs, 11 | nerdGraphUrl 12 | ) => { 13 | return new Promise(resolve => { 14 | const query = `query Query($guids: [EntityGuid]!) { 15 | actor { 16 | entities(guids: $guids) { 17 | reporting 18 | alertSeverity 19 | name 20 | guid 21 | domain 22 | type 23 | entityType 24 | tags { 25 | key 26 | values 27 | } 28 | QueueSample: nrdbQuery(nrql: "${SqsQuery} ${timeNrql}", timeout: 120) { 29 | results 30 | } 31 | } 32 | } 33 | }`; 34 | 35 | batchEntityQuery(key, query, entities, config, nerdGraphUrl).then( 36 | async entityData => { 37 | // get cloud pricing 38 | const cloudPricing = await fetchPricing( 39 | `${BASE_URL}/amazon/sqs/pricing.json` 40 | ); 41 | const priceData = cloudPricing?.priceData; 42 | 43 | if (priceData) { 44 | // massage entity data 45 | entityData.forEach(e => { 46 | // move samples top level 47 | const QueueSample = e?.QueueSample?.results?.[0] || {}; 48 | 49 | // clean up keys 50 | Object.keys(QueueSample).forEach(key => { 51 | if (!QueueSample[key]) { 52 | delete QueueSample[key]; 53 | } else if (key.startsWith('latest.')) { 54 | const newKey = key.replace('latest.', ''); 55 | QueueSample[newKey] = QueueSample[key]; 56 | delete QueueSample[key]; 57 | } 58 | }); 59 | e.QueueSample = QueueSample; 60 | 61 | const region = priceData?.mapping?.[e.tags?.['aws.awsRegion']?.[0]]; 62 | const pricing = 63 | priceData?.regions?.[region.replace('Europe', 'EU')]; 64 | 65 | if (pricing) { 66 | e.messageCostStandardPerReq = parseFloat( 67 | pricing?.['Standard per Requests']?.price || 0 68 | ); 69 | } 70 | }); 71 | } 72 | 73 | resolve(entityData); 74 | } 75 | ); 76 | }); 77 | }; 78 | -------------------------------------------------------------------------------- /backend/processor/queries.js: -------------------------------------------------------------------------------- 1 | exports.workloadEntityFetchQuery = (guid, cursor) => { 2 | return ` 3 | { 4 | actor { 5 | entity(guid: "${guid}") { 6 | ... on WorkloadEntity { 7 | guid 8 | name 9 | relatedEntities${cursor ? `(cursor: "${cursor}")` : ''} { 10 | results { 11 | target { 12 | entity { 13 | guid 14 | name 15 | type 16 | entityType 17 | domain 18 | account { 19 | name 20 | id 21 | } 22 | } 23 | } 24 | } 25 | nextCursor 26 | } 27 | } 28 | } 29 | } 30 | } 31 | `; 32 | }; 33 | 34 | exports.k8sClusterExpansionQuery = (guid, cursor) => { 35 | return `query k8sClusterExpansionQuery($clusterGuid: EntityGuid!) { 36 | actor { 37 | entity(guid: "${guid}") { 38 | relatedEntities(filter: {direction: OUTBOUND, entityDomainTypes: {include: {type: "HOST", domain: "INFRA"}}} ${ 39 | cursor ? `,cursor: "${cursor}"` : '' 40 | }) { 41 | results { 42 | target { 43 | entity { 44 | guid 45 | name 46 | domain 47 | type 48 | entityType 49 | } 50 | } 51 | } 52 | nextCursor 53 | } 54 | } 55 | } 56 | }`; 57 | }; 58 | -------------------------------------------------------------------------------- /backend/processor/utils/AWS_HOST.js: -------------------------------------------------------------------------------- 1 | const fetch = (...args) => 2 | import('node-fetch').then(({ default: fetch }) => fetch(...args)); 3 | 4 | const { AWS_LOCATIONS_URL } = require('../constants'); 5 | 6 | exports.fetchAwsEc2Locations = () => { 7 | return new Promise(resolve => { 8 | fetch(AWS_LOCATIONS_URL).then(async response => { 9 | try { 10 | const locationData = await response.json(); 11 | resolve({ locationData }); 12 | } catch (e) { 13 | console.log('failed @ fetchAwsEc2Locations', e); // eslint-disable-line no-console 14 | resolve(null); 15 | } 16 | }); 17 | }); 18 | }; 19 | 20 | exports.fetchAwsEc2RegionPricing = (region, locations) => { 21 | return new Promise(resolve => { 22 | const regionLabel = Object.keys(locations).find( 23 | l => locations[l].code === region 24 | ); 25 | 26 | if (regionLabel) { 27 | const URL = `https://b0.p.awsstatic.com/pricing/2.0/meteredUnitMaps/ec2/USD/current/ec2-ondemand-without-sec-sel/${regionLabel}/Linux/index.json`; 28 | 29 | fetch(URL).then(async response => { 30 | try { 31 | const pricingDataUnsorted = await response.json(); 32 | const pricingData = Object.keys( 33 | pricingDataUnsorted?.regions?.[regionLabel] || {} 34 | ) 35 | .map( 36 | instance => pricingDataUnsorted?.regions?.[regionLabel][instance] 37 | ) 38 | .sort((a, b) => a.price - b.price); 39 | 40 | resolve({ pricingData, region, regionLabel }); 41 | } catch (e) { 42 | console.log('failed @ fetchAwsEc2RegionPricing', e, region); // eslint-disable-line no-console 43 | resolve(null); 44 | } 45 | }); 46 | } else { 47 | resolve(null); 48 | } 49 | }); 50 | }; 51 | -------------------------------------------------------------------------------- /backend/serverless.yml: -------------------------------------------------------------------------------- 1 | service: ${file(./package.json):name} 2 | 3 | provider: 4 | name: aws 5 | runtime: nodejs16.x 6 | memorySize: 256 7 | region: us-east-1 8 | stage: ${opt:stage, 'dev'} 9 | environment: 10 | REGION: ${self:provider.region} 11 | STAGE: ${opt:stage, 'dev'} 12 | LOG_LVL: "DEBUG" 13 | iamRoleStatements: 14 | - Effect: Allow 15 | Action: 16 | - lambda:InvokeFunction 17 | Resource: arn:aws:lambda:${self:provider.region}:*:function:optimizer-${self:provider.stage}-optimize-processor 18 | 19 | functions: 20 | main: 21 | timeout: 30 22 | handler: api/index.router 23 | events: 24 | - http: 25 | path: / 26 | method: any 27 | cors: 28 | origin: "*" # <-- Specify allowed origin 29 | headers: "*" 30 | allowCredentials: true 31 | - http: 32 | path: /{proxy+} 33 | method: any 34 | cors: 35 | origin: "*" # <-- Specify allowed origin 36 | headers: "*" 37 | allowCredentials: true 38 | 39 | optimize-processor: 40 | timeout: 900 41 | handler: processor/index.optimize -------------------------------------------------------------------------------- /catalog/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "tagline": "Right-size AWS, GCP, and Azure", 3 | "repository": "https://github.com/newrelic/nr1-cloud-optimize.git", 4 | "details": "Cloud Optimize analyzes your cloud environment using the New Relic Infrastructure cloud integrations. \n\n The application compares the size of your instances to their utilization, identifying resources that are sized larger than needed. Cloud Optimize will estimate your savings by optimizing resource size.\n\n Users of Cloud Optimize are able to select the hosts, regions and other configurations to specify their unique business use cases.\n\n This application supports AWS, GCP, Azure and Alibaba cloud infrastructures.", 5 | "categoryTerms": [ 6 | "azure", 7 | "gcp", 8 | "aws", 9 | "containers", 10 | "kubernetes", 11 | "infrastructure", 12 | "network" 13 | ], 14 | "keywords": [ 15 | "infrastructure" 16 | ], 17 | "support": { 18 | "issues": { 19 | "url": "https://github.com/newrelic/nr1-cloud-optimize/issues" 20 | }, 21 | "email": { 22 | "address": "opensource+nr1-cloud-optimize@newrelic.com" 23 | }, 24 | "community": { 25 | "url": "https://discuss.newrelic.com/t/cloud-optimizer-nerdpack/82936" 26 | } 27 | }, 28 | "whatsNew": "- V3 Release" 29 | } -------------------------------------------------------------------------------- /catalog/documentation.md: -------------------------------------------------------------------------------- 1 | ## Usage 2 | 3 | Cloud Optimize analyzes your cloud environment using the [`New Relic Infrastructure`](https://newrelic.com/products/infrastructure) cloud integrations. 4 | 5 | The application compares the size of your instances to their utilization, identifying resources that are sized larger than needed. Cloud Optimize will estimate your savings by optimizing resource size. 6 | 7 | Users of Cloud Optimize are able to select the hosts, regions and other configurations to specify their unique business use cases. 8 | 9 | This application supports AWS, GCP, Azure and Alibaba cloud infrastructures. 10 | 11 | > In-Context Application Optimization requires APM 12 | 13 | ## Open Source License 14 | 15 | This project is distributed under the [Apache 2 license](https://github.com/newrelic/nr1-cloud-optimize/blob/main/LICENSE). 16 | 17 | ## Dependencies 18 | 19 | Requires [`New Relic Infrastructure`](https://newrelic.com/products/infrastructure). 20 | 21 | You'll get the best possible data out of this application if you also: 22 | 23 | - [Activate the EC2 integration](https://docs.newrelic.com/docs/integrations/amazon-integrations/get-started/connect-aws-infrastructure) to group by your AWS cloud provider account. 24 | - [Activate the Azure VMs integration](https://docs.newrelic.com/docs/integrations/microsoft-azure-integrations/azure-integrations-list/azure-vms-monitoring-integration) to group by your Azure cloud provider account. 25 | - [Activate the Google Compute integration](https://docs.newrelic.com/docs/integrations/google-cloud-platform-integrations/gcp-integrations-list/google-compute-engine-monitoring-integration) to group by your GCP cloud provider account. 26 | - [Install APM on your applications](https://docs.newrelic.com/docs/agents/manage-apm-agents/installation/install-agent#apm-install) to group by application. 27 | 28 | ## Getting started 29 | 30 | First, ensure that you have [Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) and [NPM](https://www.npmjs.com/get-npm) installed. If you're unsure whether you have one or both of them installed, run the following command(s) (If you have them installed these commands will return a version number, if not, the commands won't be recognized): 31 | 32 | ```bash 33 | git --version 34 | npm -v 35 | ``` 36 | 37 | Next, install the [NR1 CLI](https://one.newrelic.com/launcher/developer-center.launcher) by going to [this link](https://one.newrelic.com/launcher/developer-center.launcher) and following the instructions (5 minutes or less) to install and setup your New Relic development environment. 38 | 39 | Next, to clone this repository and run the code locally against your New Relic data, execute the following commands: 40 | 41 | ```bash 42 | nr1 nerdpack:clone -r https://github.com/newrelic/nr1-cloud-optimize.git 43 | cd nr1-cloud-optimize 44 | nr1 nerdpack:serve 45 | ``` 46 | 47 | Visit [https://one.newrelic.com/?nerdpacks=local](https://one.newrelic.com/?nerdpacks=local), navigate to the Nerdpack, and :sparkles: 48 | 49 | ## Deploying this Nerdpack 50 | 51 | Open a command prompt in the nerdpack's directory and run the following commands. 52 | 53 | ```bash 54 | # To create a new uuid for the nerdpack so that you can deploy it to your account: 55 | # nr1 nerdpack:uuid -g [--profile=your_profile_name] 56 | 57 | # To see a list of APIkeys / profiles available in your development environment: 58 | # nr1 profiles:list 59 | 60 | nr1 nerdpack:publish [--profile=your_profile_name] 61 | nr1 nerdpack:deploy [-c [DEV|BETA|STABLE]] [--profile=your_profile_name] 62 | nr1 nerdpack:subscribe [-c [DEV|BETA|STABLE]] [--profile=your_profile_name] 63 | ``` 64 | 65 | Visit [https://one.newrelic.com](https://one.newrelic.com), navigate to the Nerdpack, and :sparkles: 66 | 67 | ## Community Support 68 | 69 | New Relic hosts and moderates an online forum where you can interact with New Relic employees as well as other customers to get help and share best practices. Like all New Relic open source community projects, there's a related topic in the New Relic Explorers Hub. You can find this project's topic/threads here: 70 | 71 | [https://discuss.newrelic.com/t/cloud-optimizer-nerdpack/82936](https://discuss.newrelic.com/t/cloud-optimizer-nerdpack/82936) 72 | 73 | Please do not report issues with Cloud Optimize to New Relic Global Technical Support. Instead, visit the [`Explorers Hub`](https://discuss.newrelic.com/c/build-on-new-relic) for troubleshooting and best-practices. 74 | 75 | ## Issues / Enhancement Requests 76 | 77 | Issues and enhancement requests can be submitted in the [Issues tab of this repository](https://github.com/newrelic/nr1-cloud-optimize/issues). Please search for and review the existing open issues before submitting a new issue. 78 | 79 | ## Contributing 80 | 81 | Contributions are welcome (and if you submit a Enhancement Request, expect to be invited to contribute it yourself :grin:). Please review our [Contributors Guide](https://github.com/newrelic/nr1-cloud-optimize/blob/main/CONTRIBUTING.md). 82 | 83 | Keep in mind that when you submit your pull request, you'll need to sign the CLA via the click-through using CLA-Assistant. If you'd like to execute our corporate CLA, or if you have any questions, please drop us an email at opensource@newrelic.com. 84 | -------------------------------------------------------------------------------- /catalog/screenshots/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/newrelic/nr1-cloud-optimize/baa3bc53bdc48613619b9169dbe68260444ee244/catalog/screenshots/.gitkeep -------------------------------------------------------------------------------- /catalog/screenshots/nr1-cloud-optimize-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/newrelic/nr1-cloud-optimize/baa3bc53bdc48613619b9169dbe68260444ee244/catalog/screenshots/nr1-cloud-optimize-1.png -------------------------------------------------------------------------------- /catalog/screenshots/nr1-cloud-optimize-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/newrelic/nr1-cloud-optimize/baa3bc53bdc48613619b9169dbe68260444ee244/catalog/screenshots/nr1-cloud-optimize-2.png -------------------------------------------------------------------------------- /catalog/screenshots/nr1-cloud-optimize-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/newrelic/nr1-cloud-optimize/baa3bc53bdc48613619b9169dbe68260444ee244/catalog/screenshots/nr1-cloud-optimize-3.png -------------------------------------------------------------------------------- /examples/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/newrelic/nr1-cloud-optimize/baa3bc53bdc48613619b9169dbe68260444ee244/examples/.gitkeep -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/newrelic/nr1-cloud-optimize/baa3bc53bdc48613619b9169dbe68260444ee244/icon.png -------------------------------------------------------------------------------- /launchers/cloud-optimize-launcher/catalog/screenshots/nr1-cloud-optimize-launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/newrelic/nr1-cloud-optimize/baa3bc53bdc48613619b9169dbe68260444ee244/launchers/cloud-optimize-launcher/catalog/screenshots/nr1-cloud-optimize-launcher.png -------------------------------------------------------------------------------- /launchers/cloud-optimize-launcher/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/newrelic/nr1-cloud-optimize/baa3bc53bdc48613619b9169dbe68260444ee244/launchers/cloud-optimize-launcher/icon.png -------------------------------------------------------------------------------- /launchers/cloud-optimize-launcher/nr1.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaType": "LAUNCHER", 3 | "id": "cloud-optimize-launcher", 4 | "description": "Optimize your workloads", 5 | "displayName": "Cloud Optimize", 6 | "rootNerdletId": "optimizer-beta" 7 | } 8 | -------------------------------------------------------------------------------- /messages.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "title": "Announcing the updated Results Page", 4 | "messages": [ 5 | "New Relic Labs is happy to announce the release of the updated Results listing, updated with a streamlined layout that emphasizes cost impact. Check it out in the Beta view!", 6 | "More details on this release are available in the discussions thread: https://github.com/newrelic/nr1-cloud-optimize/discussions/127" 7 | ] 8 | } 9 | ] 10 | -------------------------------------------------------------------------------- /nerdlets/optimization-configuration/nr1.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaType": "NERDLET", 3 | "id": "optimization-configuration-nerdlet", 4 | "displayName": "Optimization Configuration", 5 | "description": "" 6 | } 7 | -------------------------------------------------------------------------------- /nerdlets/optimization-configuration/styles.scss: -------------------------------------------------------------------------------- 1 | .my-nerdlet { 2 | outline: 1px solid black; 3 | } 4 | 5 | .configForm label { 6 | width: 350px !important; 7 | } 8 | 9 | .configForm input { 10 | width: 300px !important; 11 | } 12 | -------------------------------------------------------------------------------- /nerdlets/optimizer-beta/components/collectionEdit/index.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { Modal } from 'nr1'; 3 | import DataContext from '../../context/data'; 4 | import CollectionEdit from './edit'; 5 | 6 | // eslint-disable-next-line no-unused-vars 7 | export default function CollectionEditModal(props) { 8 | const dataContext = useContext(DataContext); 9 | const { editCollectionOpen, updateDataState } = dataContext; 10 | 11 | return ( 12 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /nerdlets/optimizer-beta/components/collectionView/menuBar/index.js: -------------------------------------------------------------------------------- 1 | import React, { useContext, useMemo } from 'react'; 2 | import { 3 | BlockText, 4 | Button, 5 | SegmentedControl, 6 | SegmentedControlItem, 7 | TextField, 8 | Dropdown, 9 | DropdownItem, 10 | UserStorageMutation 11 | } from 'nr1'; 12 | import DataContext from '../../../context/data'; 13 | 14 | // eslint-disable-next-line no-unused-vars 15 | export default function CollectionMenuBar(props) { 16 | const dataContext = useContext(DataContext); 17 | const { updateDataState, userConfig, sortBy } = dataContext; 18 | const { 19 | collectionView, 20 | // setCollectionView, 21 | setSearch, 22 | setSortBy 23 | } = props; 24 | 25 | return useMemo(() => { 26 | return ( 27 | <> 28 |
29 | 30 | 38 |    39 | 47 | 48 |
49 | 50 |
55 | { 57 | const newUserConfig = { ...userConfig, collectionView: value }; 58 | updateDataState({ userConfig: newUserConfig }); 59 | UserStorageMutation.mutate({ 60 | actionType: UserStorageMutation.ACTION_TYPE.WRITE_DOCUMENT, 61 | collection: 'USER_CONFIG', 62 | documentId: 'config', 63 | document: newUserConfig 64 | }); 65 | }} 66 | value={userConfig?.collectionView} 67 | > 68 | 73 | 80 | 81 |
82 | 83 |
90 | 91 | setSortBy('Most recent')}> 92 | Most recent 93 | 94 | setSortBy('Cost')}>Cost 95 | setSortBy('Name')}>Name 96 | 97 |
98 | 99 |
100 | 101 | setSearch(e.target.value)} 106 | /> 107 | 108 | ); 109 | }, [userConfig, collectionView, sortBy]); 110 | } 111 | -------------------------------------------------------------------------------- /nerdlets/optimizer-beta/components/helpModal/help.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { Button, HeadingText, Tile, Icon } from 'nr1'; 3 | import DataContext from '../../context/data'; 4 | 5 | // eslint-disable-next-line no-unused-vars 6 | export default function Help(props) { 7 | const dataContext = useContext(DataContext); 8 | const { updateDataState } = dataContext; 9 | 10 | return ( 11 | <> 12 | 14 | window.open( 15 | 'https://github.com/newrelic/nr1-cloud-optimize/issues/new?assignees=&labels=bug%2C+needs-triage&template=bug_report.md&title=', 16 | '_blank' 17 | ) 18 | } 19 | > 20 |
21 | 22 | 23 |   Report a Bug 24 | 25 |
26 |
27 |
28 | 30 | window.open( 31 | 'https://github.com/newrelic/nr1-cloud-optimize/issues/new?assignees=&labels=enhancement%2C+needs-triage&template=enhancement.md&title=', 32 | '_blank' 33 | ) 34 | } 35 | > 36 |
37 | 38 | 39 |   Submit a Feature Request 40 | 41 |
42 |
43 |
44 | 46 | window.open( 47 | 'https://github.com/newrelic/nr1-cloud-optimize/discussions/new', 48 | '_blank' 49 | ) 50 | } 51 | > 52 |
53 | 54 | 55 |   Ask a Question 56 | 57 |
58 |
59 |
60 | 62 | window.open( 63 | 'https://github.com/newrelic/nr1-cloud-optimize#readme', 64 | '_blank' 65 | ) 66 | } 67 | > 68 |
69 | 70 | 71 |   Open the Documentation 72 | 73 |
74 |
75 |
76 |   77 | 83 | 84 | ); 85 | } 86 | -------------------------------------------------------------------------------- /nerdlets/optimizer-beta/components/helpModal/index.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { Modal } from 'nr1'; 3 | import DataContext from '../../context/data'; 4 | import Help from './help'; 5 | 6 | // eslint-disable-next-line no-unused-vars 7 | export default function HelpModal() { 8 | const dataContext = useContext(DataContext); 9 | const { helpModalOpen, updateDataState } = dataContext; 10 | 11 | return ( 12 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /nerdlets/optimizer-beta/components/jobHistory/index.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { Modal } from 'nr1'; 3 | import DataContext from '../../context/data'; 4 | import History from './history'; 5 | 6 | // eslint-disable-next-line no-unused-vars 7 | export default function JobHistoryModal(props) { 8 | const dataContext = useContext(DataContext); 9 | const { jobHistoryOpen, updateDataState } = dataContext; 10 | 11 | return ( 12 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /nerdlets/optimizer-beta/components/messages/index.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { Card, CardBody, UserStorageMutation, BlockText, Button } from 'nr1'; 3 | import DataContext from '../../context/data'; 4 | 5 | // eslint-disable-next-line no-unused-vars 6 | export default function Messages(props) { 7 | const dataContext = useContext(DataContext); 8 | const { messages, userConfig, getUserConfig } = dataContext; 9 | 10 | const dismissMessage = async id => { 11 | if (!userConfig.dismissed) userConfig.dismissed = []; 12 | userConfig.dismissed.push(id); 13 | await UserStorageMutation.mutate({ 14 | actionType: UserStorageMutation.ACTION_TYPE.WRITE_DOCUMENT, 15 | collection: 'USER_CONFIG', 16 | documentId: 'config', 17 | document: userConfig 18 | }); 19 | getUserConfig(); 20 | }; 21 | 22 | const checkMessages = (userConfig, message) => { 23 | const dismissed = userConfig?.dismissed || []; 24 | for (let z = 0; z < dismissed.length; z++) { 25 | if (message.id === dismissed[z] || message.title === dismissed[z]) { 26 | return false; 27 | } 28 | } 29 | return true; 30 | }; 31 | 32 | const filteredMessages = messages.filter(m => checkMessages(userConfig, m)); 33 | 34 | if (!userConfig || !filteredMessages || filteredMessages.length === 0) { 35 | return <>; 36 | } 37 | 38 | return ( 39 | <> 40 | 48 | 49 |

50 | Messages 51 |

52 | 53 | {filteredMessages.map((m, i) => { 54 | return ( 55 |
56 |

57 | {m.title} {' '} 58 | 66 |

67 | 68 |
69 |
    70 | {m.messages.map((msg, m) => ( 71 |
  • 72 | {msg} 73 |
  • 74 | ))} 75 |
76 |
77 |
78 | ); 79 | })} 80 |
81 |
82 | 83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /nerdlets/optimizer-beta/components/settings/modal.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import DataContext from '../../context/data'; 3 | import { Switch, HeadingText, Modal, Card, CardBody } from 'nr1'; 4 | 5 | // eslint-disable-next-line no-unused-vars 6 | export default function SettingsModal(props) { 7 | const dataContext = useContext(DataContext); 8 | const { settingsModalOpen, updateDataState, obfuscate } = dataContext; 9 | 10 | return ( 11 | <> 12 | 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /nerdlets/optimizer-beta/context/queries.js: -------------------------------------------------------------------------------- 1 | import { ngql } from 'nr1'; 2 | 3 | export const initQuery = ngql` 4 | { 5 | actor { 6 | user { 7 | email 8 | id 9 | } 10 | accounts(scope: GLOBAL) { 11 | id 12 | name 13 | } 14 | } 15 | } 16 | `; 17 | 18 | export const catalogNerdpacksQuery = ngql`{ 19 | actor { 20 | nr1Catalog { 21 | nerdpacks { 22 | id 23 | visibility 24 | metadata { 25 | repository 26 | displayName 27 | } 28 | } 29 | } 30 | } 31 | }`; 32 | 33 | export const workloadDiscoveryQuery = (accountId, cursor) => ngql` 34 | { 35 | actor { 36 | entitySearch(query: "type = 'WORKLOAD' and tags.accountId = '${accountId}'") { 37 | results${cursor ? `(cursor: "${cursor}")` : ''} { 38 | entities { 39 | ... on WorkloadEntityOutline { 40 | guid 41 | name 42 | alertSeverity 43 | account { 44 | id 45 | name 46 | } 47 | tags { 48 | key 49 | values 50 | } 51 | } 52 | } 53 | nextCursor 54 | } 55 | } 56 | } 57 | } 58 | `; 59 | 60 | export const userApiKeysQuery = (userId, accountId, cursor) => ngql`{ 61 | actor { 62 | apiAccess { 63 | keySearch(query: {types: USER, scope: {userIds: ${userId}, accountIds: ${accountId}}}${ 64 | cursor ? `, cursor: "${cursor}"` : '' 65 | }) { 66 | keys { 67 | ... on ApiAccessUserKey { 68 | id 69 | name 70 | key 71 | } 72 | } 73 | nextCursor 74 | } 75 | } 76 | } 77 | } 78 | `; 79 | 80 | export const userApiCreateQuery = (userId, accountId) => ngql`mutation { 81 | apiAccessCreateKeys(keys: {user: {accountId: ${accountId}, name: "NR1-OPTIMIZER-KEY", notes: "Used for the nr1 optimizer application", userId: ${userId}}}) { 82 | createdKeys { 83 | id 84 | key 85 | name 86 | notes 87 | type 88 | createdAt 89 | } 90 | } 91 | } 92 | `; 93 | -------------------------------------------------------------------------------- /nerdlets/optimizer-beta/images/histOption.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/newrelic/nr1-cloud-optimize/baa3bc53bdc48613619b9169dbe68260444ee244/nerdlets/optimizer-beta/images/histOption.png -------------------------------------------------------------------------------- /nerdlets/optimizer-beta/images/runOption.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/newrelic/nr1-cloud-optimize/baa3bc53bdc48613619b9169dbe68260444ee244/nerdlets/optimizer-beta/images/runOption.png -------------------------------------------------------------------------------- /nerdlets/optimizer-beta/index.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useContext } from 'react'; 2 | import { PlatformStateContext, nerdlet } from 'nr1'; 3 | import DataContext, { DataProvider } from './context/data'; 4 | import CollectionCreateModal from './components/collectionCreate'; 5 | import Optimizer from './components/optimizer'; 6 | import CollectionEditModal from './components/collectionEdit'; 7 | import JobHistoryModal from './components/jobHistory'; 8 | import SettingsModal from './components/settings/modal'; 9 | import AdminBar from '../shared/components/adminBar'; 10 | import HelpModal from './components/helpModal'; 11 | 12 | function OptimizerRoot() { 13 | useEffect(() => { 14 | nerdlet.setConfig({ 15 | accountPicker: true, 16 | accountPickerValues: [...nerdlet.ACCOUNT_PICKER_DEFAULT_VALUES] 17 | }); 18 | }, []); 19 | 20 | const platformContext = useContext(PlatformStateContext); 21 | 22 | return ( 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 |
34 | ); 35 | } 36 | 37 | export default OptimizerRoot; 38 | -------------------------------------------------------------------------------- /nerdlets/optimizer-beta/nr1.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaType": "NERDLET", 3 | "id": "optimizer-beta", 4 | "displayName": "Cloud Optimize", 5 | "description": "Optimize your workloads" 6 | } 7 | -------------------------------------------------------------------------------- /nerdlets/optimizer-beta/styles.scss: -------------------------------------------------------------------------------- 1 | .my-nerdlet { 2 | outline: 1px solid black; 3 | } 4 | 5 | h3:not(.u-unstyledH3) { 6 | font-size: unset; 7 | } 8 | 9 | .lds-ring { 10 | display: inline-block; 11 | position: relative; 12 | width: 80px; 13 | height: 80px; 14 | } 15 | .lds-ring div { 16 | box-sizing: border-box; 17 | display: block; 18 | position: absolute; 19 | width: 64px; 20 | height: 64px; 21 | margin: 8px; 22 | border: 8px solid rgb(0, 0, 0); 23 | border-radius: 50%; 24 | animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; 25 | border-color: rgb(0, 0, 0) transparent transparent transparent; 26 | } 27 | .lds-ring div:nth-child(1) { 28 | animation-delay: -0.45s; 29 | } 30 | .lds-ring div:nth-child(2) { 31 | animation-delay: -0.3s; 32 | } 33 | .lds-ring div:nth-child(3) { 34 | animation-delay: -0.15s; 35 | } 36 | @keyframes lds-ring { 37 | 0% { 38 | transform: rotate(0deg); 39 | } 40 | 100% { 41 | transform: rotate(360deg); 42 | } 43 | } 44 | 45 | .lds-ripple { 46 | display: inline-block; 47 | position: relative; 48 | width: 80px; 49 | height: 80px; 50 | } 51 | .lds-ripple div { 52 | position: absolute; 53 | border: 4px solid rgb(0, 0, 0); 54 | opacity: 1; 55 | border-radius: 50%; 56 | animation: lds-ripple 1s cubic-bezier(0, 0.2, 0.8, 1) infinite; 57 | } 58 | .lds-ripple div:nth-child(2) { 59 | animation-delay: -0.5s; 60 | } 61 | @keyframes lds-ripple { 62 | 0% { 63 | top: 36px; 64 | left: 36px; 65 | width: 0; 66 | height: 0; 67 | opacity: 1; 68 | } 69 | 100% { 70 | top: 0px; 71 | left: 0px; 72 | width: 72px; 73 | height: 72px; 74 | opacity: 0; 75 | } 76 | } 77 | 78 | .flex-center { 79 | display: flex; 80 | justify-content: center; 81 | align-items: center; 82 | } 83 | 84 | .lds-facebook { 85 | display: inline-block; 86 | position: relative; 87 | width: 80px; 88 | height: 80px; 89 | } 90 | .lds-facebook div { 91 | display: inline-block; 92 | position: absolute; 93 | left: 8px; 94 | width: 16px; 95 | background: rgb(0, 0, 0); 96 | animation: lds-facebook 1.2s cubic-bezier(0, 0.5, 0.5, 1) infinite; 97 | } 98 | .lds-facebook div:nth-child(1) { 99 | left: 8px; 100 | animation-delay: -0.24s; 101 | } 102 | .lds-facebook div:nth-child(2) { 103 | left: 32px; 104 | animation-delay: -0.12s; 105 | } 106 | .lds-facebook div:nth-child(3) { 107 | left: 56px; 108 | animation-delay: 0; 109 | } 110 | @keyframes lds-facebook { 111 | 0% { 112 | top: 8px; 113 | height: 64px; 114 | } 115 | 50%, 116 | 100% { 117 | top: 24px; 118 | height: 32px; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /nerdlets/optimizer/components/collectionCreate/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useContext } from 'react'; 2 | import { 3 | Modal, 4 | Spinner, 5 | Button, 6 | HeadingText, 7 | Card, 8 | BlockText, 9 | CardBody, 10 | Checkbox, 11 | CheckboxGroup, 12 | TextField, 13 | AccountStorageMutation 14 | } from 'nr1'; 15 | import DataContext from '../../context/data'; 16 | import { v4 as uuidv4 } from 'uuid'; 17 | 18 | // eslint-disable-next-line no-unused-vars 19 | export default function CollectionCreateModal(props) { 20 | const dataContext = useContext(DataContext); 21 | const { 22 | createCollectionOpen, 23 | selectedAccount, 24 | email, 25 | workloads, 26 | updateDataState, 27 | fetchingAccessibleWorkloads, 28 | fetchWorkloadCollections 29 | } = dataContext; 30 | 31 | const [writingDocument, setWriteState] = useState(false); 32 | const [name, setName] = useState(''); 33 | const [searchText, setSearch] = useState(''); 34 | const [checkboxValues, setCheckBoxValues] = useState([]); 35 | const filteredWorkloads = workloads.filter(w => 36 | w.name.toLowerCase().includes(searchText.toLocaleLowerCase()) 37 | ); 38 | 39 | const writeDocument = () => { 40 | setWriteState(true); 41 | const filteredWorkloads = workloads 42 | .filter(w => checkboxValues.includes(w.guid)) 43 | .map(w => ({ 44 | account: { id: w.account.id, name: w.account.name }, 45 | guid: w.guid, 46 | name: w.name, 47 | tags: w.tags 48 | })); 49 | const document = { 50 | name, 51 | workloads: filteredWorkloads, 52 | createdBy: email, 53 | lastEditedBy: email 54 | }; 55 | 56 | AccountStorageMutation.mutate({ 57 | accountId: selectedAccount.id, 58 | actionType: AccountStorageMutation.ACTION_TYPE.WRITE_DOCUMENT, 59 | collection: 'workloadCollections', 60 | documentId: uuidv4(), 61 | document 62 | }).then(value => { 63 | // eslint-disable-next-line no-console 64 | console.log('wrote document', value); 65 | 66 | setWriteState(false); 67 | fetchWorkloadCollections(); 68 | setCheckBoxValues([]); 69 | updateDataState({ createCollectionOpen: false }); 70 | }); 71 | }; 72 | 73 | return ( 74 | 159 | ); 160 | } 161 | -------------------------------------------------------------------------------- /nerdlets/optimizer/components/collectionEdit/edit.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useContext, useEffect } from 'react'; 2 | import { 3 | Spinner, 4 | Button, 5 | HeadingText, 6 | Card, 7 | CardBody, 8 | Checkbox, 9 | CheckboxGroup, 10 | TextField, 11 | AccountStorageMutation 12 | } from 'nr1'; 13 | import DataContext from '../../context/data'; 14 | 15 | // eslint-disable-next-line no-unused-vars 16 | export default function CollectionEdit(props) { 17 | const dataContext = useContext(DataContext); 18 | const { 19 | selectedAccount, 20 | email, 21 | workloads, 22 | updateDataState, 23 | fetchingAccessibleWorkloads, 24 | fetchWorkloadCollections, 25 | editCollectionId, 26 | accountCollection 27 | } = dataContext; 28 | 29 | const [writingDocument, setWriteState] = useState(false); 30 | const [name, setName] = useState(''); 31 | const [searchText, setSearch] = useState(''); 32 | const [checkboxValues, setCheckBoxValues] = useState([]); 33 | const filteredWorkloads = workloads.filter(w => 34 | w.name.toLowerCase().includes(searchText.toLocaleLowerCase()) 35 | ); 36 | 37 | useEffect(() => { 38 | const foundCollection = (accountCollection || []).find( 39 | a => a.id === editCollectionId 40 | ); 41 | 42 | if (foundCollection) { 43 | const { document } = foundCollection; 44 | const values = document.workloads.map(w => w.guid); 45 | setName(document.name); 46 | setCheckBoxValues(values); 47 | } 48 | }, [editCollectionId]); 49 | 50 | const writeDocument = () => { 51 | setWriteState(true); 52 | const filteredWorkloads = workloads 53 | .filter(w => checkboxValues.includes(w.guid)) 54 | .map(w => ({ 55 | account: { id: w.account.id, name: w.account.name }, 56 | guid: w.guid, 57 | name: w.name, 58 | tags: w.tags 59 | })); 60 | const document = { 61 | name, 62 | workloads: filteredWorkloads, 63 | createdBy: email, 64 | lastEditedBy: email 65 | }; 66 | 67 | AccountStorageMutation.mutate({ 68 | accountId: selectedAccount.id, 69 | actionType: AccountStorageMutation.ACTION_TYPE.WRITE_DOCUMENT, 70 | collection: 'workloadCollections', 71 | documentId: editCollectionId, 72 | document 73 | }).then(value => { 74 | // eslint-disable-next-line no-console 75 | console.log('updated document', value); 76 | 77 | setWriteState(false); 78 | fetchWorkloadCollections(); 79 | updateDataState({ editCollectionOpen: false }); 80 | }); 81 | }; 82 | 83 | return ( 84 | <> 85 | 89 | Edit Collection 90 | 91 | setName(e.target.value)} 97 | /> 98 | {fetchingAccessibleWorkloads ? ( 99 | <> 100 | 101 |  Fetching workloads... 102 | 103 | ) : ( 104 | <> 105 | setSearch(e.target.value)} 111 | /> 112 | 113 | 114 | setCheckBoxValues(v)} 117 | > 118 | {filteredWorkloads.map(w => ( 119 | 124 | ))} 125 | 126 | 127 | 128 | 129 | 130 | {checkboxValues.length} / {filteredWorkloads.length} workloads 131 | selected 132 | 133 | 134 | 135 | )} 136 |
137 | 145 |   146 | 152 | 153 | ); 154 | } 155 | -------------------------------------------------------------------------------- /nerdlets/optimizer/components/collectionEdit/index.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { Modal } from 'nr1'; 3 | import DataContext from '../../context/data'; 4 | import CollectionEdit from './edit'; 5 | 6 | // eslint-disable-next-line no-unused-vars 7 | export default function CollectionEditModal(props) { 8 | const dataContext = useContext(DataContext); 9 | const { editCollectionOpen, updateDataState } = dataContext; 10 | 11 | return ( 12 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /nerdlets/optimizer/components/jobHistory/index.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { Modal } from 'nr1'; 3 | import DataContext from '../../context/data'; 4 | import History from './history'; 5 | 6 | // eslint-disable-next-line no-unused-vars 7 | export default function JobHistoryModal(props) { 8 | const dataContext = useContext(DataContext); 9 | const { jobHistoryOpen, updateDataState } = dataContext; 10 | 11 | return ( 12 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /nerdlets/optimizer/components/messages/index.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { Card, CardBody, UserStorageMutation, BlockText, Button } from 'nr1'; 3 | import DataContext from '../../context/data'; 4 | 5 | // eslint-disable-next-line no-unused-vars 6 | export default function Messages(props) { 7 | const dataContext = useContext(DataContext); 8 | const { messages, userConfig, getUserConfig } = dataContext; 9 | 10 | const dismissMessage = async id => { 11 | if (!userConfig.dismissed) userConfig.dismissed = []; 12 | userConfig.dismissed.push(id); 13 | await UserStorageMutation.mutate({ 14 | actionType: UserStorageMutation.ACTION_TYPE.WRITE_DOCUMENT, 15 | collection: 'USER_CONFIG', 16 | documentId: 'config', 17 | document: userConfig 18 | }); 19 | getUserConfig(); 20 | }; 21 | 22 | const checkMessages = (userConfig, message) => { 23 | const dismissed = userConfig?.dismissed || []; 24 | for (let z = 0; z < dismissed.length; z++) { 25 | if (message.id === dismissed[z] || message.title === dismissed[z]) { 26 | return false; 27 | } 28 | } 29 | return true; 30 | }; 31 | 32 | const filteredMessages = messages.filter(m => checkMessages(userConfig, m)); 33 | 34 | if (!userConfig || !filteredMessages || filteredMessages.length === 0) { 35 | return <>; 36 | } 37 | 38 | return ( 39 | <> 40 | 48 | 49 |

50 | Messages 51 |

52 | 53 | {filteredMessages.map((m, i) => { 54 | return ( 55 |
56 |

57 | {m.title} {' '} 58 | 66 |

67 | 68 |
69 |
    70 | {m.messages.map((msg, m) => ( 71 |
  • 72 | {msg} 73 |
  • 74 | ))} 75 |
76 |
77 |
78 | ); 79 | })} 80 |
81 |
82 | 83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /nerdlets/optimizer/components/quickStart/index.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { Card, CardHeader, CardBody, Steps, StepsItem, Button } from 'nr1'; 3 | import DataContext from '../../context/data'; 4 | import runOption from '../../images/runOption.png'; 5 | import histOption from '../../images/histOption.png'; 6 | 7 | // eslint-disable-next-line no-unused-vars 8 | export default function QuickStart(props) { 9 | const dataContext = useContext(DataContext); 10 | const { updateDataState } = dataContext; 11 | 12 | return ( 13 | <> 14 | 15 | 16 | 17 | 18 | 23 | 33 | 34 | 38 | 43 | 44 | 45 | 49 | Large workloads may take time to process. 50 |
51 | Run 52 |
53 | 57 | History 58 | 59 | 63 | Rinse and repeat. 64 | 65 |
66 |
67 |
68 | 69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /nerdlets/optimizer/components/settings/modal.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import DataContext from '../../context/data'; 3 | import { Switch, HeadingText, Modal, Card, CardBody } from 'nr1'; 4 | 5 | // eslint-disable-next-line no-unused-vars 6 | export default function SettingsModal(props) { 7 | const dataContext = useContext(DataContext); 8 | const { settingsModalOpen, updateDataState, obfuscate } = dataContext; 9 | 10 | return ( 11 | <> 12 | 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /nerdlets/optimizer/context/queries.js: -------------------------------------------------------------------------------- 1 | import { ngql } from 'nr1'; 2 | 3 | export const initQuery = ngql` 4 | { 5 | actor { 6 | user { 7 | email 8 | id 9 | } 10 | accounts(scope: GLOBAL) { 11 | id 12 | name 13 | } 14 | } 15 | } 16 | `; 17 | 18 | export const catalogNerdpacksQuery = ngql`{ 19 | actor { 20 | nr1Catalog { 21 | nerdpacks { 22 | id 23 | visibility 24 | metadata { 25 | repository 26 | displayName 27 | } 28 | } 29 | } 30 | } 31 | }`; 32 | 33 | export const workloadDiscoveryQuery = (accountId, cursor) => ngql` 34 | { 35 | actor { 36 | entitySearch(query: "type = 'WORKLOAD' and tags.accountId = '${accountId}'") { 37 | results${cursor ? `(cursor: "${cursor}")` : ''} { 38 | entities { 39 | ... on WorkloadEntityOutline { 40 | guid 41 | name 42 | alertSeverity 43 | account { 44 | id 45 | name 46 | } 47 | tags { 48 | key 49 | values 50 | } 51 | } 52 | } 53 | nextCursor 54 | } 55 | } 56 | } 57 | } 58 | `; 59 | 60 | export const userApiKeysQuery = (userId, accountId, cursor) => ngql`{ 61 | actor { 62 | apiAccess { 63 | keySearch(query: {types: USER, scope: {userIds: ${userId}, accountIds: ${accountId}}}${ 64 | cursor ? `, cursor: "${cursor}"` : '' 65 | }) { 66 | keys { 67 | ... on ApiAccessUserKey { 68 | id 69 | name 70 | key 71 | } 72 | } 73 | nextCursor 74 | } 75 | } 76 | } 77 | } 78 | `; 79 | 80 | export const userApiCreateQuery = (userId, accountId) => ngql`mutation { 81 | apiAccessCreateKeys(keys: {user: {accountId: ${accountId}, name: "NR1-OPTIMIZER-KEY", notes: "Used for the nr1 optimizer application", userId: ${userId}}}) { 82 | createdKeys { 83 | id 84 | key 85 | name 86 | notes 87 | type 88 | createdAt 89 | } 90 | } 91 | } 92 | `; 93 | -------------------------------------------------------------------------------- /nerdlets/optimizer/images/histOption.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/newrelic/nr1-cloud-optimize/baa3bc53bdc48613619b9169dbe68260444ee244/nerdlets/optimizer/images/histOption.png -------------------------------------------------------------------------------- /nerdlets/optimizer/images/runOption.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/newrelic/nr1-cloud-optimize/baa3bc53bdc48613619b9169dbe68260444ee244/nerdlets/optimizer/images/runOption.png -------------------------------------------------------------------------------- /nerdlets/optimizer/index.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useContext } from 'react'; 2 | import { PlatformStateContext, nerdlet } from 'nr1'; 3 | import DataContext, { DataProvider } from './context/data'; 4 | import CollectionCreateModal from './components/collectionCreate'; 5 | import Optimizer from './components/optimizer'; 6 | import CollectionEditModal from './components/collectionEdit'; 7 | import JobHistoryModal from './components/jobHistory'; 8 | import SettingsModal from './components/settings/modal'; 9 | import AdminBar from '../shared/components/adminBar'; 10 | 11 | function OptimizerRoot() { 12 | useEffect(() => { 13 | nerdlet.setConfig({ 14 | accountPicker: true, 15 | accountPickerValues: [...nerdlet.ACCOUNT_PICKER_DEFAULT_VALUES] 16 | }); 17 | }, []); 18 | 19 | const platformContext = useContext(PlatformStateContext); 20 | 21 | return ( 22 |
23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |
32 | ); 33 | } 34 | 35 | export default OptimizerRoot; 36 | -------------------------------------------------------------------------------- /nerdlets/optimizer/nr1.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaType": "NERDLET", 3 | "id": "optimizer", 4 | "displayName": "Cloud Optimize", 5 | "description": "Optimize your workloads" 6 | } 7 | -------------------------------------------------------------------------------- /nerdlets/optimizer/styles.scss: -------------------------------------------------------------------------------- 1 | .my-nerdlet { 2 | outline: 1px solid black; 3 | } 4 | 5 | h3:not(.u-unstyledH3) { 6 | font-size: unset; 7 | } 8 | 9 | .lds-ring { 10 | display: inline-block; 11 | position: relative; 12 | width: 80px; 13 | height: 80px; 14 | } 15 | .lds-ring div { 16 | box-sizing: border-box; 17 | display: block; 18 | position: absolute; 19 | width: 64px; 20 | height: 64px; 21 | margin: 8px; 22 | border: 8px solid rgb(0, 0, 0); 23 | border-radius: 50%; 24 | animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; 25 | border-color: rgb(0, 0, 0) transparent transparent transparent; 26 | } 27 | .lds-ring div:nth-child(1) { 28 | animation-delay: -0.45s; 29 | } 30 | .lds-ring div:nth-child(2) { 31 | animation-delay: -0.3s; 32 | } 33 | .lds-ring div:nth-child(3) { 34 | animation-delay: -0.15s; 35 | } 36 | @keyframes lds-ring { 37 | 0% { 38 | transform: rotate(0deg); 39 | } 40 | 100% { 41 | transform: rotate(360deg); 42 | } 43 | } 44 | 45 | .lds-ripple { 46 | display: inline-block; 47 | position: relative; 48 | width: 80px; 49 | height: 80px; 50 | } 51 | .lds-ripple div { 52 | position: absolute; 53 | border: 4px solid rgb(0, 0, 0); 54 | opacity: 1; 55 | border-radius: 50%; 56 | animation: lds-ripple 1s cubic-bezier(0, 0.2, 0.8, 1) infinite; 57 | } 58 | .lds-ripple div:nth-child(2) { 59 | animation-delay: -0.5s; 60 | } 61 | @keyframes lds-ripple { 62 | 0% { 63 | top: 36px; 64 | left: 36px; 65 | width: 0; 66 | height: 0; 67 | opacity: 1; 68 | } 69 | 100% { 70 | top: 0px; 71 | left: 0px; 72 | width: 72px; 73 | height: 72px; 74 | opacity: 0; 75 | } 76 | } 77 | 78 | .flex-center { 79 | display: flex; 80 | justify-content: center; 81 | align-items: center; 82 | } 83 | 84 | .lds-facebook { 85 | display: inline-block; 86 | position: relative; 87 | width: 80px; 88 | height: 80px; 89 | } 90 | .lds-facebook div { 91 | display: inline-block; 92 | position: absolute; 93 | left: 8px; 94 | width: 16px; 95 | background: rgb(0, 0, 0); 96 | animation: lds-facebook 1.2s cubic-bezier(0, 0.5, 0.5, 1) infinite; 97 | } 98 | .lds-facebook div:nth-child(1) { 99 | left: 8px; 100 | animation-delay: -0.24s; 101 | } 102 | .lds-facebook div:nth-child(2) { 103 | left: 32px; 104 | animation-delay: -0.12s; 105 | } 106 | .lds-facebook div:nth-child(3) { 107 | left: 56px; 108 | animation-delay: 0; 109 | } 110 | @keyframes lds-facebook { 111 | 0% { 112 | top: 8px; 113 | height: 64px; 114 | } 115 | 50%, 116 | 100% { 117 | top: 24px; 118 | height: 32px; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /nerdlets/results-beta/components/costBar/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function CostBar(props) { 4 | const { type } = props; 5 | 6 | const workloadColors = [ 7 | 'rgba(13, 54, 196, 0.1)', 8 | 'rgba(13, 54, 196, 0.3)', 9 | 'rgba(13, 54, 196, 0.6)', 10 | 'rgba(13, 54, 196, 0.8)', 11 | '#0D36C4' 12 | ]; 13 | 14 | const serviceColors = [ 15 | 'rgba(107, 37, 196, 0.1)', 16 | 'rgba(107, 37, 196, 0.3)', 17 | 'rgba(107, 37, 196, 0.6)', 18 | 'rgba(107, 37, 196, 0.8)', 19 | '#6B25C4' 20 | ]; 21 | 22 | const colors = type === 'service' ? serviceColors : workloadColors; 23 | 24 | return ( 25 |
29 | 30 | 31 | 34 | 35 | 36 | 44 | 48 | 50 | 53 | 56 | 59 | 62 | 65 | 67 |
32 | % of {type === 'service' ? 'service' : 'workload'} cost 33 |
37 | 38 | 39 | 40 | 41 | 42 | 43 |
49 | 0 51 | 20 52 | 54 | 40 55 | 57 | 60 58 | 60 | 80 61 | 63 | 100 64 | 66 |
68 |
69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /nerdlets/results-beta/components/entityView/AWSELASTICACHEREDISNODE.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import AwsElasticacheRedisNodeViewStandard from './AWSELASTICACHEREDISNODE_STANDARD'; 3 | import { Card, CardHeader, CardBody } from 'nr1'; 4 | import { generateFakeName } from '../../../shared/utils'; 5 | import _ from 'lodash'; 6 | 7 | // eslint-disable-next-line no-unused-vars 8 | export default function AwsElasticacheRedisNodeView(props) { 9 | const { entities, obfuscate } = props; 10 | const groupedEntities = _.groupBy(entities, e => e?.cacheClusterId); 11 | 12 | return ( 13 | 14 | 24 | 25 | {Object.keys(groupedEntities).map(groupKey => { 26 | let groupName = obfuscate 27 | ? generateFakeName() 28 | : groupKey || 'Unknown'; 29 | groupName = groupName === 'undefined' ? 'Unknown' : groupName; 30 | 31 | return ( 32 | 38 | ); 39 | })} 40 | 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /nerdlets/results-beta/components/entityView/AWSELASTICSEARCHNODE.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { generateFakeName } from '../../../shared/utils'; 3 | import _ from 'lodash'; 4 | import { Card, CardHeader, CardBody } from 'nr1'; 5 | import AwsElasticsearchNodeViewStandard from './AWSELASTICSEARCHNODE_STANDARD'; 6 | 7 | // eslint-disable-next-line no-unused-vars 8 | export default function AwsElasticsearchNodeView(props) { 9 | const { entities, obfuscate } = props; 10 | const groupedEntities = _.groupBy(entities, e => e?.clusterName); 11 | 12 | return ( 13 | 14 | 24 | 25 | {Object.keys(groupedEntities).map(groupKey => { 26 | let groupName = obfuscate 27 | ? generateFakeName() 28 | : groupKey || 'Unknown'; 29 | groupName = groupName === 'undefined' ? 'Unknown' : groupName; 30 | 31 | return ( 32 | 38 | ); 39 | })} 40 | 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /nerdlets/results-beta/components/entityView/HOST.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import HostStandardView from './HOST STANDARD'; 3 | import HostECSView from './HOST_ECS'; 4 | import HostKubernetesView from './HOST_K8S'; 5 | 6 | // eslint-disable-next-line no-unused-vars 7 | export default function HostView(props) { 8 | const { entities, obfuscate, entityTableMode, updateDataState } = props; 9 | 10 | const standardHosts = []; 11 | const k8sHosts = []; 12 | const ecsHosts = []; 13 | 14 | for (let z = 0; z < entities.length; z++) { 15 | if (entities[z].K8sContainerData) { 16 | k8sHosts.push(entities[z]); 17 | } else if (entities[z].EcsContainerData) { 18 | ecsHosts.push(entities[z]); 19 | } else { 20 | standardHosts.push(entities[z]); 21 | } 22 | } 23 | 24 | return ( 25 | <> 26 | {standardHosts.length > 0 && ( 27 | 33 | )} 34 | {k8sHosts.length > 0 && ( 35 | 41 | )} 42 | {ecsHosts.length > 0 && ( 43 | 49 | )} 50 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /nerdlets/results-beta/components/entityView/HOST_ECS.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { generateFakeName } from '../../../shared/utils'; 3 | import _ from 'lodash'; 4 | import { Card, CardHeader, CardBody } from 'nr1'; 5 | import HostEcsViewStandard from './HOST_ECS_STANDARD'; 6 | 7 | // eslint-disable-next-line no-unused-vars 8 | export default function HostECSView(props) { 9 | const { entities, obfuscate, entityTableMode, updateDataState } = props; 10 | const groupedEntities = _.groupBy(entities, e => e?.ecsCluster); 11 | 12 | return ( 13 | 14 | 18 | 19 | {Object.keys(groupedEntities).map(groupKey => { 20 | let groupName = obfuscate 21 | ? generateFakeName() 22 | : groupKey || 'Unknown'; 23 | groupName = groupName === 'undefined' ? 'Unknown' : groupName; 24 | 25 | return ( 26 | 35 | ); 36 | })} 37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /nerdlets/results-beta/components/entityView/HOST_K8S.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { generateFakeName } from '../../../shared/utils'; 3 | import _ from 'lodash'; 4 | import { Card, CardHeader, CardBody } from 'nr1'; 5 | import HostKubernetesViewStandard from './HOST_K8S_STANDARD'; 6 | 7 | // eslint-disable-next-line no-unused-vars 8 | export default function HostKubernetesView(props) { 9 | const { entities, obfuscate, entityTableMode, updateDataState } = props; 10 | const groupedEntities = _.groupBy(entities, e => e?.clusterName); 11 | 12 | return ( 13 | 14 | 18 | 19 | {Object.keys(groupedEntities).map(groupKey => { 20 | let groupName = obfuscate 21 | ? generateFakeName() 22 | : groupKey || 'Unknown'; 23 | groupName = groupName === 'undefined' ? 'Unknown' : groupName; 24 | 25 | return ( 26 | 35 | ); 36 | })} 37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /nerdlets/results-beta/components/entityView/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import AwsAlbView from './AWSALB'; 3 | import AwsAPIGatewayView from './AWSAPIGATEWAYAPI'; 4 | import AwsElasticacheRedisNodeView from './AWSELASTICACHEREDISNODE'; 5 | import AwsElasticsearchNodeView from './AWSELASTICSEARCHNODE'; 6 | import AwsElbView from './AWSELB'; 7 | import AwsLambdaFunctionView from './AWSLAMBDAFUNCTION'; 8 | import AwsRdsDbInstanceView from './AWSRDSDBINSTANCE'; 9 | import AwsSqsView from './AWSSQSQUEUE'; 10 | import HostView from './HOST'; 11 | 12 | // const ignore = ['AWSELASTICSEARCHCLUSTER']; 13 | 14 | // eslint-disable-next-line no-unused-vars 15 | export default function EntityView(props) { 16 | const { 17 | entities, 18 | group, 19 | obfuscate, 20 | // cardListView, 21 | entityTableMode, 22 | updateDataState 23 | } = props; 24 | 25 | const renderView = (group, entities) => { 26 | switch (group) { 27 | case 'HOST': 28 | return ( 29 | 35 | ); 36 | case 'AWSAPIGATEWAYAPI': 37 | return ( 38 | 43 | ); 44 | case 'AWSELASTICSEARCHNODE': 45 | return ( 46 | 52 | ); 53 | case 'AWSELASTICACHEREDISNODE': 54 | return ( 55 | 61 | ); 62 | case 'AWSELB': 63 | return ( 64 | 69 | ); 70 | 71 | // return ; 72 | case 'AWSALB': 73 | return ( 74 | 79 | ); 80 | case 'AWSSQSQUEUE': 81 | return ( 82 | 87 | ); 88 | case 'AWSLAMBDAFUNCTION': 89 | return ( 90 | 95 | ); 96 | case 'AWSRDSDBINSTANCE': 97 | return ( 98 | 103 | ); 104 | default: 105 | // console.log(`unsupported entityType: ${group}`); 106 | return ''; 107 | // return ignore.includes(group) ? ( 108 | // '' 109 | // ) : ( 110 | // <> 111 | // No view available for {group} 112 | //
113 | // 114 | // ); 115 | } 116 | }; 117 | 118 | return <>{renderView(group, entities)}; 119 | } 120 | -------------------------------------------------------------------------------- /nerdlets/results-beta/components/resultPanel/costSummary.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Tile, 4 | Stack, 5 | StackItem, 6 | HeadingText, 7 | BlockText, 8 | Popover, 9 | PopoverTrigger, 10 | Icon, 11 | PopoverBody 12 | } from 'nr1'; 13 | 14 | // eslint-disable-next-line no-unused-vars 15 | export default function CostSummary(props) { 16 | const { cost, tileType } = props; 17 | 18 | if (!cost) { 19 | return ''; 20 | } 21 | 22 | return ( 23 | <> 24 |
25 | {/*
*/} 26 | 27 | 28 | 29 | 30 | Known Cost {' '} 31 | 32 | 33 | 34 | 35 | 36 | 37 |  Exactly matched price based on public price 38 | lists  39 | 40 | 41 | 42 | 43 | ${(cost?.known || 0).toFixed(2)} 44 | 45 | 46 | 47 | 51 | 52 | Estimated Cost {' '} 53 | 54 | 55 | 56 | 57 | 58 | 59 |  Best effort estimation based on public pricing  60 | 61 | 62 | 63 | 64 | ${(cost?.estimated || 0).toFixed(2)} 65 | 66 | 67 | 68 | 72 | 73 | Known + Estimated Cost {' '} 74 | 75 | 76 | 77 | 78 | 79 | 80 |  Total determined running cost based on known and 81 | estimated cost  82 | 83 | 84 | 85 | 86 | 87 | ${((cost?.known || 0) + (cost?.estimated || 0)).toFixed(2)} 88 | 89 | 90 | 91 | {/* 92 | 93 | 94 | Optimized Run Cost{' '} 95 | 96 | 97 | 98 | 99 | 100 | 101 |  If optimization was to occur, this would be the new 102 | cost  103 | 104 | 105 | 106 | 107 | ${cost.optimizedRun} 108 | 109 | */} 110 | 111 | 119 | 120 | Potential Saving{' '} 121 | 122 | 123 | 124 | 125 | 126 | 127 |  If optimization was to occur, this would be the 128 | potential savings  129 | 130 | 131 | 132 | 133 | ${(cost?.potentialSaving || 0).toFixed(2)} 134 | 135 | 136 | 137 |
138 | 139 | ); 140 | } 141 | -------------------------------------------------------------------------------- /nerdlets/results-beta/components/tags/bar.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import DataContext from '../../context/data'; 3 | import { Button, Tooltip } from 'nr1'; 4 | 5 | // eslint-disable-next-line no-unused-vars 6 | export default function TagBar(props) { 7 | const dataContext = useContext(DataContext); 8 | const { updateDataState, selectedTags, recalculate } = dataContext; 9 | 10 | const tagKeys = Object.keys(selectedTags); 11 | const tagCount = tagKeys.map(key => Object.keys(selectedTags[key])).flat() 12 | .length; 13 | 14 | const updateTags = async (t, key) => { 15 | delete selectedTags[t][key]; 16 | 17 | if (Object.keys(selectedTags[t]).length === 0) { 18 | delete selectedTags[t]; 19 | } 20 | 21 | await updateDataState({ selectedTags }); 22 | recalculate(); 23 | }; 24 | 25 | return ( 26 | <> 27 |
28 | 29 | 38 | 39 | {tagCount > 0 && ( 40 | 47 | )} 48 |
49 |
50 |
56 | {tagKeys.map(tag => { 57 | const tagValues = Object.keys(selectedTags[tag]); 58 | 59 | return tagValues.map(v => { 60 | return ( 61 |
65 | 0 ? '4px' : '0px', 68 | fontSize: '12px', 69 | marginTop: '10px', 70 | marginBottom: '20px', 71 | marginRight: '10px', 72 | backgroundColor: '#D0F0FF', 73 | cursor: 'pointer', 74 | borderRadius: '3px' 75 | }} 76 | onClick={() => updateTags(tag, v)} 77 | > 78 | {tag}:{v}   79 | X  80 | 81 |
82 | ); 83 | }); 84 | })} 85 |
86 | 87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /nerdlets/results-beta/components/tags/modal.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import DataContext from '../../context/data'; 3 | import { 4 | Button, 5 | BlockText, 6 | HeadingText, 7 | Modal, 8 | CardSectionBody, 9 | Checkbox, 10 | Card, 11 | CardSectionHeader, 12 | CardBody, 13 | CardSection 14 | } from 'nr1'; 15 | 16 | // eslint-disable-next-line no-unused-vars 17 | export default function TagModal(props) { 18 | const dataContext = useContext(DataContext); 19 | const { 20 | tagModalOpen, 21 | updateDataState, 22 | entityTags, 23 | selectedTags, 24 | recalculate 25 | } = dataContext; 26 | 27 | return ( 28 | <> 29 | 99 | 100 | ); 101 | } 102 | -------------------------------------------------------------------------------- /nerdlets/results-beta/components/workloadView/index.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import DataContext from '../../context/data'; 3 | import _ from 'lodash'; 4 | import EntityView from '../entityView'; 5 | import calculate, { checkTags } from '../../context/calculate'; 6 | import { StackItem, Card, CardBody } from 'nr1'; 7 | import { generateFakeName, pickWorkloadColor } from '../../../shared/utils'; 8 | 9 | // eslint-disable-next-line no-unused-vars 10 | export default function WorkloadView(props) { 11 | const dataContext = useContext(DataContext); 12 | const { 13 | selectedTags, 14 | obfuscate, 15 | userConfig, 16 | entityTableMode, 17 | updateDataState 18 | } = dataContext; 19 | const { workload, workloadCostTotal } = props; 20 | 21 | if (obfuscate) { 22 | workload.results.forEach(e => { 23 | e.name = generateFakeName(); 24 | }); 25 | } 26 | 27 | const groupedEntities = _.groupBy(workload.results, e => e?.type); 28 | 29 | return ( 30 | <> 31 | {Object.keys(groupedEntities) 32 | .sort((a, b) => { 33 | const costA = calculate({ 34 | workloadData: { results: groupedEntities[a] } 35 | }); 36 | const costB = calculate({ 37 | workloadData: { results: groupedEntities[b] } 38 | }); 39 | 40 | const serviceTotalA = costA?.known || 0 + costA?.estimated || 0; 41 | const serviceTotalB = costB?.known || 0 + costB?.estimated || 0; 42 | 43 | return serviceTotalB - serviceTotalA; 44 | }) 45 | .map(g => { 46 | const filteredEntities = groupedEntities[g].filter(e => 47 | checkTags(e, selectedTags) 48 | ); 49 | 50 | if (groupedEntities[g].length === 0) { 51 | return null; 52 | } 53 | 54 | const cost = calculate({ 55 | workloadData: { results: groupedEntities[g] } 56 | }); 57 | const serviceTotal = cost?.known || 0 + cost?.estimated || 0; 58 | const costPercentage = (serviceTotal / workloadCostTotal) * 100; 59 | const { costColor, costFontColor } = pickWorkloadColor( 60 | isNaN(costPercentage) ? 0 : costPercentage 61 | ); 62 | 63 | return ( 64 | 65 | 66 | 67 | {costPercentage > 0 && ( 68 |
76 |
85 |  {costPercentage.toFixed(2)}% of workload cost 86 |
87 |
88 | )} 89 | 98 |
99 |
100 |
101 | ); 102 | })} 103 | 104 | ); 105 | } 106 | -------------------------------------------------------------------------------- /nerdlets/results-beta/context/provideSuggestions.js: -------------------------------------------------------------------------------- 1 | const { options } = require('../../suggestions-configuration/options'); 2 | 3 | export default function(entity, config) { 4 | entity.suggestions = []; 5 | const rules = options.find(o => o.type === entity?.type)?.suggestionsConfig; 6 | const configuredSuggestions = config?.[entity?.type] || {}; 7 | 8 | if (rules) { 9 | const ruleKeys = Object.keys(rules); 10 | 11 | ruleKeys.forEach(ruleKey => { 12 | const ruleData = rules[ruleKey]; 13 | 14 | if (ruleData.type === 'number') { 15 | const configValue = configuredSuggestions[ruleKey] 16 | ? parseFloat(configuredSuggestions[ruleKey]) 17 | : null; 18 | 19 | // if config value is 0 do not run the rule 20 | if (configValue !== 0 && configValue !== '0') { 21 | // run rule 22 | const { 23 | getValue, 24 | operator, 25 | response, 26 | label, 27 | message, 28 | defaultValue 29 | } = ruleData; 30 | const value = getValue(entity); 31 | const checkValue = configValue || defaultValue; 32 | 33 | if (value) { 34 | if ( 35 | (operator === 'below' && value < checkValue) || 36 | (operator === 'less' && value < checkValue) || 37 | (operator === 'above' && value > checkValue) || 38 | (operator === 'more' && value > checkValue) || 39 | (operator === 'equal' && value === checkValue) || 40 | (operator === 'exact' && value === checkValue) 41 | ) { 42 | entity.suggestions.push({ 43 | response, 44 | message: message || `${label} detected`, 45 | value, 46 | checkValue 47 | }); 48 | } 49 | } 50 | } 51 | } 52 | }); 53 | } 54 | 55 | if (entity.suggestions.length === 0) delete entity.suggestions; 56 | } 57 | -------------------------------------------------------------------------------- /nerdlets/results-beta/context/queries.js: -------------------------------------------------------------------------------- 1 | import { ngql } from 'nr1'; 2 | 3 | export const initQuery = ngql` 4 | { 5 | actor { 6 | user { 7 | email 8 | id 9 | } 10 | accounts(scope: GLOBAL) { 11 | id 12 | name 13 | } 14 | } 15 | } 16 | `; 17 | 18 | export const catalogNerdpacksQuery = `{ 19 | actor { 20 | nr1Catalog { 21 | nerdpacks { 22 | id 23 | visibility 24 | metadata { 25 | repository 26 | displayName 27 | } 28 | } 29 | } 30 | } 31 | }`; 32 | 33 | export const workloadDiscoveryQuery = cursor => ngql` 34 | { 35 | actor { 36 | entitySearch(query: "type = 'WORKLOAD'") { 37 | results${cursor ? `(cursor: "${cursor}")` : ''} { 38 | entities { 39 | ... on WorkloadEntityOutline { 40 | guid 41 | name 42 | alertSeverity 43 | account { 44 | id 45 | name 46 | } 47 | tags { 48 | key 49 | values 50 | } 51 | } 52 | } 53 | nextCursor 54 | } 55 | } 56 | } 57 | } 58 | `; 59 | 60 | export const userApiKeysQuery = (userId, accountId, cursor) => ngql`{ 61 | actor { 62 | apiAccess { 63 | keySearch(query: {types: USER, scope: {userIds: ${userId}, accountIds: ${accountId}}}${ 64 | cursor ? `, cursor: "${cursor}"` : '' 65 | }) { 66 | keys { 67 | ... on ApiAccessUserKey { 68 | id 69 | name 70 | key 71 | } 72 | } 73 | nextCursor 74 | } 75 | } 76 | } 77 | } 78 | `; 79 | 80 | export const userApiCreateQuery = (userId, accountId) => ngql`mutation { 81 | apiAccessCreateKeys(keys: {user: {accountId: ${accountId}, name: "NR1-OPTIMIZER-KEY", notes: "Used for the nr1 optimizer application", userId: ${userId}}}) { 82 | createdKeys { 83 | id 84 | key 85 | name 86 | notes 87 | type 88 | createdAt 89 | } 90 | } 91 | } 92 | `; 93 | -------------------------------------------------------------------------------- /nerdlets/results-beta/index.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { NerdletStateContext } from 'nr1'; 3 | import { DataProvider } from './context/data'; 4 | import Results from './components/results'; 5 | import TagModal from './components/tags/modal'; 6 | 7 | function ResultsRoot() { 8 | const nerdletContext = useContext(NerdletStateContext); 9 | 10 | return ( 11 |
19 | 20 | 21 | 22 | 23 |
24 | ); 25 | } 26 | 27 | export default ResultsRoot; 28 | -------------------------------------------------------------------------------- /nerdlets/results-beta/nr1.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaType": "NERDLET", 3 | "id": "results-nerdlet-beta", 4 | "displayName": "Results", 5 | "description": "" 6 | } 7 | -------------------------------------------------------------------------------- /nerdlets/results/components/entityView/AWSAPIGATEWAYAPI.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { 3 | navigation, 4 | Table, 5 | TableHeader, 6 | TableHeaderCell, 7 | TableRow, 8 | TableRowCell, 9 | EntityTitleTableRowCell, 10 | Card, 11 | CardHeader, 12 | CardBody 13 | } from 'nr1'; 14 | import calculate from '../../context/calculate'; 15 | 16 | // eslint-disable-next-line no-unused-vars 17 | export default function AwsAPIGatewayView(props) { 18 | const { entities } = props; 19 | const [column, setColumn] = useState(0); 20 | const [sortingType, setSortingType] = useState( 21 | TableHeaderCell.SORTING_TYPE.NONE 22 | ); 23 | 24 | const onClickTableHeaderCell = (nextColumn, { nextSortingType }) => { 25 | if (nextColumn === column) { 26 | setSortingType(nextSortingType); 27 | } else { 28 | setSortingType(nextSortingType); 29 | setColumn(nextColumn); 30 | } 31 | }; 32 | 33 | const cost = calculate({ workloadData: { results: entities } }); 34 | 35 | const headers = [ 36 | { key: 'Name', value: ({ item }) => item.name }, 37 | { key: 'Region', value: ({ item }) => item?.tags?.['aws.awsRegion']?.[0] }, 38 | { key: 'Requests', value: ({ item }) => item?.ApiGatewaySample?.requests }, 39 | { key: 'API Call Price', value: ({ item }) => item.apiCallPrice }, 40 | { 41 | key: 'Cost (Requests * Call Price)', 42 | value: ({ item }) => item.requestCost 43 | } 44 | ]; 45 | 46 | return ( 47 | <> 48 | 49 | 57 | 60 | 61 | 62 | {headers.map((h, i) => ( 63 | // eslint-disable-next-line react/jsx-key 64 | onClickTableHeaderCell(i, data)} 73 | > 74 | {h.key} 75 | 76 | ))} 77 | 78 | 79 | {({ item }) => { 80 | return ( 81 | 82 | navigation.openStackedEntity(item.guid)} 85 | /> 86 | 87 | {item?.tags?.['aws.awsRegion']?.[0]} 88 | 89 | 90 | {item?.ApiGatewaySample?.requests} 91 | 92 | {item.apiCallPrice} 93 | {item.requestCost} 94 | 95 | ); 96 | }} 97 |
98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | {() => { 108 | return ( 109 | 110 | 111 | 112 | 113 | 116 | Total 117 | 118 | {cost.estimated} 119 | 120 | ); 121 | }} 122 |
123 |
124 |
125 | 126 | ); 127 | } 128 | -------------------------------------------------------------------------------- /nerdlets/results/components/entityView/AWSELASTICACHEREDISNODE.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import AwsElasticacheRedisNodeViewStandard from './AWSELASTICACHEREDISNODE_STANDARD'; 3 | import { Card, CardHeader, CardBody } from 'nr1'; 4 | import { generateFakeName } from '../../../shared/utils'; 5 | import _ from 'lodash'; 6 | 7 | // eslint-disable-next-line no-unused-vars 8 | export default function AwsElasticacheRedisNodeView(props) { 9 | const { entities, obfuscate } = props; 10 | const groupedEntities = _.groupBy(entities, e => e?.cacheClusterId); 11 | 12 | return ( 13 | 14 | 24 | 25 | {Object.keys(groupedEntities).map(groupKey => { 26 | let groupName = obfuscate 27 | ? generateFakeName() 28 | : groupKey || 'Unknown'; 29 | groupName = groupName === 'undefined' ? 'Unknown' : groupName; 30 | 31 | return ( 32 | 38 | ); 39 | })} 40 | 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /nerdlets/results/components/entityView/AWSELASTICSEARCHNODE.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { generateFakeName } from '../../../shared/utils'; 3 | import _ from 'lodash'; 4 | import { Card, CardHeader, CardBody } from 'nr1'; 5 | import AwsElasticsearchNodeViewStandard from './AWSELASTICSEARCHNODE_STANDARD'; 6 | 7 | // eslint-disable-next-line no-unused-vars 8 | export default function AwsElasticsearchNodeView(props) { 9 | const { entities, obfuscate } = props; 10 | const groupedEntities = _.groupBy(entities, e => e?.clusterName); 11 | 12 | return ( 13 | 14 | 24 | 25 | {Object.keys(groupedEntities).map(groupKey => { 26 | let groupName = obfuscate 27 | ? generateFakeName() 28 | : groupKey || 'Unknown'; 29 | groupName = groupName === 'undefined' ? 'Unknown' : groupName; 30 | 31 | return ( 32 | 38 | ); 39 | })} 40 | 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /nerdlets/results/components/entityView/AWSSQSQUEUE.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { 3 | navigation, 4 | Table, 5 | TableHeader, 6 | TableHeaderCell, 7 | TableRow, 8 | TableRowCell, 9 | Card, 10 | CardHeader, 11 | CardBody, 12 | Switch, 13 | EntityTitleTableRowCell 14 | } from 'nr1'; 15 | import calculate from '../../context/calculate'; 16 | 17 | // eslint-disable-next-line no-unused-vars 18 | export default function AwsSqsView(props) { 19 | const { entities, timeData } = props; 20 | const [hideUndetected, setUndetected] = useState(true); 21 | const [column, setColumn] = useState(0); 22 | const [sortingType, setSortingType] = useState( 23 | TableHeaderCell.SORTING_TYPE.NONE 24 | ); 25 | 26 | const onClickTableHeaderCell = (nextColumn, { nextSortingType }) => { 27 | if (nextColumn === column) { 28 | setSortingType(nextSortingType); 29 | } else { 30 | setSortingType(nextSortingType); 31 | setColumn(nextColumn); 32 | } 33 | }; 34 | 35 | const cost = calculate({ workloadData: { results: entities, timeData } }); 36 | 37 | const headers = [ 38 | { key: 'Name', value: ({ item }) => item.name }, 39 | { key: 'Region', value: ({ item }) => item?.tags?.['aws.awsRegion']?.[0] }, 40 | { 41 | key: 'Message Total', 42 | value: ({ item }) => item?.QueueSample?.numberOfMessages 43 | }, 44 | { 45 | key: 'Message Price', 46 | value: ({ item }) => item.messageCostStandardPerReq 47 | }, 48 | { 49 | key: 'Message Cost (Price * Messages)', 50 | value: ({ item }) => 51 | item.messageCostStandardPerReq * 52 | (item?.QueueSample?.numberOfMessages || 0) 53 | } 54 | ]; 55 | 56 | return ( 57 | <> 58 | 59 | 67 | 70 | setUndetected(!hideUndetected)} 73 | label="Hide queues with no messages" 74 | /> 75 | 76 | 79 | (hideUndetected && e?.QueueSample?.numberOfMessages) || 80 | !hideUndetected 81 | )} 82 | > 83 | 84 | {headers.map((h, i) => ( 85 | // eslint-disable-next-line react/jsx-key 86 | onClickTableHeaderCell(i, data)} 95 | > 96 | {h.key} 97 | 98 | ))} 99 | 100 | 101 | {({ item }) => { 102 | const QueueSample = item?.QueueSample; 103 | 104 | return ( 105 | 106 | navigation.openStackedEntity(item.guid)} 109 | /> 110 | 111 | {item?.tags?.['aws.awsRegion']?.[0]} 112 | 113 | {QueueSample?.numberOfMessages} 114 | 115 | 116 | {item?.messageCostStandardPerReq 117 | ? (item?.messageCostStandardPerReq).toFixed(10) 118 | : ''} 119 | 120 | 121 | 122 | {item.messageCostStandardPerReq * 123 | (item?.QueueSample?.numberOfMessages || 0)} 124 | 125 | 126 | ); 127 | }} 128 |
129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | {() => { 139 | return ( 140 | 141 | 142 | 143 | 144 | 147 | Total 148 | 149 | {cost.known} 150 | 151 | ); 152 | }} 153 |
154 |
155 |
156 | 157 | ); 158 | } 159 | -------------------------------------------------------------------------------- /nerdlets/results/components/entityView/HOST.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import HostStandardView from './HOST STANDARD'; 3 | import HostECSView from './HOST_ECS'; 4 | import HostKubernetesView from './HOST_K8S'; 5 | 6 | // eslint-disable-next-line no-unused-vars 7 | export default function HostView(props) { 8 | const { entities, obfuscate } = props; 9 | 10 | const standardHosts = []; 11 | const k8sHosts = []; 12 | const ecsHosts = []; 13 | 14 | for (let z = 0; z < entities.length; z++) { 15 | if (entities[z].K8sContainerData) { 16 | k8sHosts.push(entities[z]); 17 | } else if (entities[z].EcsContainerData) { 18 | ecsHosts.push(entities[z]); 19 | } else { 20 | standardHosts.push(entities[z]); 21 | } 22 | } 23 | 24 | return ( 25 | <> 26 | {standardHosts.length > 0 && ( 27 | 28 | )} 29 | {k8sHosts.length > 0 && ( 30 | 31 | )} 32 | {ecsHosts.length > 0 && ( 33 | 34 | )} 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /nerdlets/results/components/entityView/HOST_ECS.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { generateFakeName } from '../../../shared/utils'; 3 | import _ from 'lodash'; 4 | import { Card, CardHeader, CardBody } from 'nr1'; 5 | import HostEcsViewStandard from './HOST_ECS_STANDARD'; 6 | 7 | // eslint-disable-next-line no-unused-vars 8 | export default function HostECSView(props) { 9 | const { entities, obfuscate } = props; 10 | const groupedEntities = _.groupBy(entities, e => e?.ecsCluster); 11 | 12 | return ( 13 | 14 | 18 | 19 | {Object.keys(groupedEntities).map(groupKey => { 20 | let groupName = obfuscate 21 | ? generateFakeName() 22 | : groupKey || 'Unknown'; 23 | groupName = groupName === 'undefined' ? 'Unknown' : groupName; 24 | 25 | return ( 26 | 33 | ); 34 | })} 35 | 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /nerdlets/results/components/entityView/HOST_K8S.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { generateFakeName } from '../../../shared/utils'; 3 | import _ from 'lodash'; 4 | import { Card, CardHeader, CardBody } from 'nr1'; 5 | import HostKubernetesViewStandard from './HOST_K8S_STANDARD'; 6 | 7 | // eslint-disable-next-line no-unused-vars 8 | export default function HostKubernetesView(props) { 9 | const { entities, obfuscate } = props; 10 | const groupedEntities = _.groupBy(entities, e => e?.clusterName); 11 | 12 | return ( 13 | 14 | 18 | 19 | {Object.keys(groupedEntities).map(groupKey => { 20 | let groupName = obfuscate 21 | ? generateFakeName() 22 | : groupKey || 'Unknown'; 23 | groupName = groupName === 'undefined' ? 'Unknown' : groupName; 24 | 25 | return ( 26 | 33 | ); 34 | })} 35 | 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /nerdlets/results/components/entityView/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import AwsAlbView from './AWSALB'; 3 | import AwsAPIGatewayView from './AWSAPIGATEWAYAPI'; 4 | import AwsElasticacheRedisNodeView from './AWSELASTICACHEREDISNODE'; 5 | import AwsElasticsearchNodeView from './AWSELASTICSEARCHNODE'; 6 | import AwsElbView from './AWSELB'; 7 | import AwsLambdaFunctionView from './AWSLAMBDAFUNCTION'; 8 | import AwsRdsDbInstanceView from './AWSRDSDBINSTANCE'; 9 | import AwsSqsView from './AWSSQSQUEUE'; 10 | import HostView from './HOST'; 11 | 12 | // const ignore = ['AWSELASTICSEARCHCLUSTER']; 13 | 14 | // eslint-disable-next-line no-unused-vars 15 | export default function EntityView(props) { 16 | const { entities, group, obfuscate } = props; 17 | 18 | const renderView = (group, entities) => { 19 | switch (group) { 20 | case 'HOST': 21 | return ; 22 | case 'AWSAPIGATEWAYAPI': 23 | return ; 24 | case 'AWSELASTICSEARCHNODE': 25 | return ( 26 | 27 | ); 28 | case 'AWSELASTICACHEREDISNODE': 29 | return ( 30 | 34 | ); 35 | case 'AWSELB': 36 | return ; 37 | case 'AWSALB': 38 | return ; 39 | case 'AWSSQSQUEUE': 40 | return ; 41 | case 'AWSLAMBDAFUNCTION': 42 | return ; 43 | case 'AWSRDSDBINSTANCE': 44 | return ; 45 | default: 46 | // console.log(`unsupported entityType: ${group}`); 47 | return ''; 48 | // return ignore.includes(group) ? ( 49 | // '' 50 | // ) : ( 51 | // <> 52 | // No view available for {group} 53 | //
54 | // 55 | // ); 56 | } 57 | }; 58 | 59 | return <>{renderView(group, entities)}; 60 | } 61 | -------------------------------------------------------------------------------- /nerdlets/results/components/resultPanel/costSummary.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Tile, 4 | Stack, 5 | StackItem, 6 | HeadingText, 7 | BlockText, 8 | Popover, 9 | PopoverTrigger, 10 | Icon, 11 | PopoverBody 12 | } from 'nr1'; 13 | 14 | // eslint-disable-next-line no-unused-vars 15 | export default function CostSummary(props) { 16 | const { cost, tileType } = props; 17 | 18 | if (!cost) { 19 | return ''; 20 | } 21 | 22 | return ( 23 | <> 24 |
25 | {/*
*/} 26 | 27 | 28 | 29 | 30 | Known Cost {' '} 31 | 32 | 33 | 34 | 35 | 36 | 37 |  Exactly matched price based on public price 38 | lists  39 | 40 | 41 | 42 | 43 | ${(cost?.known || 0).toFixed(2)} 44 | 45 | 46 | 47 | 51 | 52 | Estimated Cost {' '} 53 | 54 | 55 | 56 | 57 | 58 | 59 |  Best effort estimation based on public pricing  60 | 61 | 62 | 63 | 64 | ${(cost?.estimated || 0).toFixed(2)} 65 | 66 | 67 | 68 | 72 | 73 | Known + Estimated Cost {' '} 74 | 75 | 76 | 77 | 78 | 79 | 80 |  Total determined running cost based on known and 81 | estimated cost  82 | 83 | 84 | 85 | 86 | 87 | ${((cost?.known || 0) + (cost?.estimated || 0)).toFixed(2)} 88 | 89 | 90 | 91 | {/* 92 | 93 | 94 | Optimized Run Cost{' '} 95 | 96 | 97 | 98 | 99 | 100 | 101 |  If optimization was to occur, this would be the new 102 | cost  103 | 104 | 105 | 106 | 107 | ${cost.optimizedRun} 108 | 109 | */} 110 | 111 | 115 | 116 | Potential Saving{' '} 117 | 118 | 119 | 120 | 121 | 122 | 123 |  If optimization was to occur, this would be the 124 | potential savings  125 | 126 | 127 | 128 | 129 | ${(cost?.potentialSaving || 0).toFixed(2)} 130 | 131 | 132 | 133 |
134 | 135 | ); 136 | } 137 | -------------------------------------------------------------------------------- /nerdlets/results/components/tags/bar.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import DataContext from '../../context/data'; 3 | import { Button, Tooltip } from 'nr1'; 4 | 5 | // eslint-disable-next-line no-unused-vars 6 | export default function TagBar(props) { 7 | const dataContext = useContext(DataContext); 8 | const { updateDataState, selectedTags, recalculate } = dataContext; 9 | 10 | const tagKeys = Object.keys(selectedTags); 11 | const tagCount = tagKeys.map(key => Object.keys(selectedTags[key])).flat() 12 | .length; 13 | 14 | const updateTags = async (t, key) => { 15 | delete selectedTags[t][key]; 16 | 17 | if (Object.keys(selectedTags[t]).length === 0) { 18 | delete selectedTags[t]; 19 | } 20 | 21 | await updateDataState({ selectedTags }); 22 | recalculate(); 23 | }; 24 | 25 | return ( 26 | <> 27 |
28 | 29 | 38 | 39 | {tagCount > 0 && ( 40 | 47 | )} 48 |
49 |
50 |
56 | {tagKeys.map(tag => { 57 | const tagValues = Object.keys(selectedTags[tag]); 58 | 59 | return tagValues.map(v => { 60 | return ( 61 |
65 | 0 ? '4px' : '0px', 68 | fontSize: '12px', 69 | marginTop: '10px', 70 | marginBottom: '20px', 71 | marginRight: '10px', 72 | backgroundColor: '#D0F0FF', 73 | cursor: 'pointer', 74 | borderRadius: '3px' 75 | }} 76 | onClick={() => updateTags(tag, v)} 77 | > 78 | {tag}:{v}   79 | X  80 | 81 |
82 | ); 83 | }); 84 | })} 85 |
86 | 87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /nerdlets/results/components/tags/modal.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import DataContext from '../../context/data'; 3 | import { 4 | Button, 5 | BlockText, 6 | HeadingText, 7 | Modal, 8 | CardSectionBody, 9 | Checkbox, 10 | Card, 11 | CardSectionHeader, 12 | CardBody, 13 | CardSection 14 | } from 'nr1'; 15 | 16 | // eslint-disable-next-line no-unused-vars 17 | export default function TagModal(props) { 18 | const dataContext = useContext(DataContext); 19 | const { 20 | tagModalOpen, 21 | updateDataState, 22 | entityTags, 23 | selectedTags, 24 | recalculate 25 | } = dataContext; 26 | 27 | return ( 28 | <> 29 | 99 | 100 | ); 101 | } 102 | -------------------------------------------------------------------------------- /nerdlets/results/components/workloadView/index.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import DataContext from '../../context/data'; 3 | import _ from 'lodash'; 4 | import EntityView from '../entityView'; 5 | import { checkTags } from '../../context/calculate'; 6 | import { StackItem, Card, CardBody } from 'nr1'; 7 | import { generateFakeName } from '../../../shared/utils'; 8 | 9 | // eslint-disable-next-line no-unused-vars 10 | export default function WorkloadView(props) { 11 | const dataContext = useContext(DataContext); 12 | const { selectedTags, obfuscate } = dataContext; 13 | const { workload } = props; 14 | 15 | if (obfuscate) { 16 | workload.results.forEach(e => { 17 | e.name = generateFakeName(); 18 | }); 19 | } 20 | 21 | const groupedEntities = _.groupBy(workload.results, e => e?.type); 22 | 23 | return ( 24 | <> 25 | {Object.keys(groupedEntities).map(g => { 26 | const filteredEntities = groupedEntities[g].filter(e => 27 | checkTags(e, selectedTags) 28 | ); 29 | 30 | return ( 31 | 32 | 33 | 34 | 40 | 41 | 42 | 43 | ); 44 | })} 45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /nerdlets/results/context/provideSuggestions.js: -------------------------------------------------------------------------------- 1 | const { options } = require('../../suggestions-configuration/options'); 2 | 3 | export default function(entity, config) { 4 | entity.suggestions = []; 5 | const rules = options.find(o => o.type === entity?.type)?.suggestionsConfig; 6 | const configuredSuggestions = config?.[entity?.type] || {}; 7 | 8 | if (rules) { 9 | const ruleKeys = Object.keys(rules); 10 | 11 | ruleKeys.forEach(ruleKey => { 12 | const ruleData = rules[ruleKey]; 13 | 14 | if (ruleData.type === 'number') { 15 | const configValue = configuredSuggestions[ruleKey] 16 | ? parseFloat(configuredSuggestions[ruleKey]) 17 | : null; 18 | 19 | // if config value is 0 do not run the rule 20 | if (configValue !== 0 && configValue !== '0') { 21 | // run rule 22 | const { 23 | getValue, 24 | operator, 25 | response, 26 | label, 27 | message, 28 | defaultValue 29 | } = ruleData; 30 | const value = getValue(entity); 31 | const checkValue = configValue || defaultValue; 32 | 33 | if (value) { 34 | if ( 35 | (operator === 'below' && value < checkValue) || 36 | (operator === 'less' && value < checkValue) || 37 | (operator === 'above' && value > checkValue) || 38 | (operator === 'more' && value > checkValue) || 39 | (operator === 'equal' && value === checkValue) || 40 | (operator === 'exact' && value === checkValue) 41 | ) { 42 | entity.suggestions.push({ 43 | response, 44 | message: message || `${label} detected`, 45 | value, 46 | checkValue 47 | }); 48 | } 49 | } 50 | } 51 | } 52 | }); 53 | } 54 | 55 | if (entity.suggestions.length === 0) delete entity.suggestions; 56 | } 57 | -------------------------------------------------------------------------------- /nerdlets/results/context/queries.js: -------------------------------------------------------------------------------- 1 | import { ngql } from 'nr1'; 2 | 3 | export const initQuery = ngql` 4 | { 5 | actor { 6 | user { 7 | email 8 | id 9 | } 10 | accounts(scope: GLOBAL) { 11 | id 12 | name 13 | } 14 | } 15 | } 16 | `; 17 | 18 | export const catalogNerdpacksQuery = `{ 19 | actor { 20 | nr1Catalog { 21 | nerdpacks { 22 | id 23 | visibility 24 | metadata { 25 | repository 26 | displayName 27 | } 28 | } 29 | } 30 | } 31 | }`; 32 | 33 | export const workloadDiscoveryQuery = cursor => ngql` 34 | { 35 | actor { 36 | entitySearch(query: "type = 'WORKLOAD'") { 37 | results${cursor ? `(cursor: "${cursor}")` : ''} { 38 | entities { 39 | ... on WorkloadEntityOutline { 40 | guid 41 | name 42 | alertSeverity 43 | account { 44 | id 45 | name 46 | } 47 | tags { 48 | key 49 | values 50 | } 51 | } 52 | } 53 | nextCursor 54 | } 55 | } 56 | } 57 | } 58 | `; 59 | 60 | export const userApiKeysQuery = (userId, accountId, cursor) => ngql`{ 61 | actor { 62 | apiAccess { 63 | keySearch(query: {types: USER, scope: {userIds: ${userId}, accountIds: ${accountId}}}${ 64 | cursor ? `, cursor: "${cursor}"` : '' 65 | }) { 66 | keys { 67 | ... on ApiAccessUserKey { 68 | id 69 | name 70 | key 71 | } 72 | } 73 | nextCursor 74 | } 75 | } 76 | } 77 | } 78 | `; 79 | 80 | export const userApiCreateQuery = (userId, accountId) => ngql`mutation { 81 | apiAccessCreateKeys(keys: {user: {accountId: ${accountId}, name: "NR1-OPTIMIZER-KEY", notes: "Used for the nr1 optimizer application", userId: ${userId}}}) { 82 | createdKeys { 83 | id 84 | key 85 | name 86 | notes 87 | type 88 | createdAt 89 | } 90 | } 91 | } 92 | `; 93 | -------------------------------------------------------------------------------- /nerdlets/results/index.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { NerdletStateContext } from 'nr1'; 3 | import { DataProvider } from './context/data'; 4 | import Results from './components/results'; 5 | import TagModal from './components/tags/modal'; 6 | 7 | function ResultsRoot() { 8 | const nerdletContext = useContext(NerdletStateContext); 9 | 10 | return ( 11 |
19 | 20 | 21 | 22 | 23 |
24 | ); 25 | } 26 | 27 | export default ResultsRoot; 28 | -------------------------------------------------------------------------------- /nerdlets/results/nr1.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaType": "NERDLET", 3 | "id": "results-nerdlet", 4 | "displayName": "Results", 5 | "description": "" 6 | } 7 | -------------------------------------------------------------------------------- /nerdlets/results/styles.scss: -------------------------------------------------------------------------------- 1 | .my-nerdlet { 2 | outline: 1px solid black; 3 | } 4 | 5 | .segmentControl button>span{ 6 | font-size: 14px !important; 7 | padding: 3px!important; 8 | } 9 | 10 | .segmentControl button{ 11 | width: 175px!important; 12 | } 13 | 14 | .center { 15 | margin: 0 auto; 16 | width: 50%; 17 | } -------------------------------------------------------------------------------- /nerdlets/shared/components/adminBar/index.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState, useContext } from 'react'; 2 | import { UserStorageMutation, Button, NerdGraphQuery } from 'nr1'; 3 | 4 | function AdminBar(props) { 5 | const [hide, setHide] = useState(false); 6 | const [email, setEmail] = useState(null); 7 | const { DataContext } = props; 8 | const dataContext = useContext(DataContext); 9 | const { getUserConfig } = dataContext; 10 | 11 | useEffect(async () => { 12 | const emailData = await NerdGraphQuery.query({ 13 | query: `{ 14 | actor { 15 | user { 16 | email 17 | } 18 | } 19 | }` 20 | }); 21 | 22 | setEmail(emailData?.data?.actor?.user?.email); 23 | }, []); 24 | 25 | const resetUserConfig = async () => { 26 | await UserStorageMutation.mutate({ 27 | actionType: UserStorageMutation.ACTION_TYPE.WRITE_DOCUMENT, 28 | collection: 'USER_CONFIG', 29 | documentId: 'config', 30 | document: {} 31 | }); 32 | await getUserConfig(); 33 | }; 34 | 35 | if (hide || !email || !email.endsWith('@newrelic.com')) { 36 | return <>; 37 | } 38 | 39 | return ( 40 |
41 | 42 | 43 | 44 |
45 | ); 46 | } 47 | 48 | export default AdminBar; 49 | -------------------------------------------------------------------------------- /nerdlets/shared/components/loader/cssLoader.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default class CssLoader extends React.Component { 4 | render() { 5 | const { loader } = this.props; 6 | 7 | if (loader === 'lds-ripple') { 8 | return ( 9 |
10 |
11 |
12 |
13 | ); 14 | } 15 | 16 | if (!loader || loader === 'lds-ring') { 17 | return ( 18 |
19 |
20 |
21 |
22 |
23 |
24 | ); 25 | } 26 | 27 | if (!loader || loader === 'lds-facebook') { 28 | return ( 29 |
30 |
31 |
32 |
33 |
34 | ); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /nerdlets/shared/components/loader/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import CssLoader from './cssLoader'; 3 | 4 | export default class Loader extends React.Component { 5 | render() { 6 | const { message, loader, reduceHeight } = this.props; 7 | const height = 8 | window.innerHeight || 9 | document.documentElement.clientHeight || 10 | document.body.clientHeight; 11 | 12 | return ( 13 | <> 14 |
18 | 19 |
20 |   21 | {message} 22 |
23 | 24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /nerdlets/shared/components/pricingSelector.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Dropdown, Modal, Menu, Form, Button } from 'semantic-ui-react'; 3 | 4 | export default class PricingSelector extends React.Component { 5 | constructor(props) { 6 | super(props); 7 | this.handleConfigurator = this.handleConfigurator.bind(this); 8 | } 9 | 10 | async handleConfigurator(e, data) { 11 | const tempConfig = this.props.config; 12 | if (data.value && data.name) { 13 | tempConfig.cloudData[data.name] = data.value; 14 | await this.props.fetchCloudPricing(tempConfig); 15 | await this.props.handleParentState( 16 | 'config', 17 | tempConfig, 18 | 'groupAndSortRecalc' 19 | ); 20 | } 21 | } 22 | 23 | render() { 24 | const amazonRegions = this.props.cloudRegions.amazon.map(region => { 25 | return { key: region.name, text: region.name, value: region.id }; 26 | }); 27 | 28 | const googleRegions = this.props.cloudRegions.google.map(region => { 29 | return { key: region.name, text: region.name, value: region.id }; 30 | }); 31 | 32 | const azureRegions = this.props.cloudRegions.azure.map(region => { 33 | return { key: region.name, text: region.name, value: region.id }; 34 | }); 35 | 36 | return ( 37 | 45 | ) : ( 46 | Set Cloud Regions 47 | ) 48 | } 49 | closeIcon 50 | > 51 | Configure Regions 52 | 53 |
54 | 55 | 56 | 64 | 65 | 66 | 67 | 75 | 76 | 77 | 78 | 86 | 87 |
88 |
89 |
90 | ); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /nerdlets/shared/images/alibabaIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/newrelic/nr1-cloud-optimize/baa3bc53bdc48613619b9169dbe68260444ee244/nerdlets/shared/images/alibabaIcon.png -------------------------------------------------------------------------------- /nerdlets/shared/images/awsIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/newrelic/nr1-cloud-optimize/baa3bc53bdc48613619b9169dbe68260444ee244/nerdlets/shared/images/awsIcon.png -------------------------------------------------------------------------------- /nerdlets/shared/images/awsIcon2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/newrelic/nr1-cloud-optimize/baa3bc53bdc48613619b9169dbe68260444ee244/nerdlets/shared/images/awsIcon2.png -------------------------------------------------------------------------------- /nerdlets/shared/images/azureIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/newrelic/nr1-cloud-optimize/baa3bc53bdc48613619b9169dbe68260444ee244/nerdlets/shared/images/azureIcon.png -------------------------------------------------------------------------------- /nerdlets/shared/images/googleIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/newrelic/nr1-cloud-optimize/baa3bc53bdc48613619b9169dbe68260444ee244/nerdlets/shared/images/googleIcon.png -------------------------------------------------------------------------------- /nerdlets/shared/images/vmwareIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/newrelic/nr1-cloud-optimize/baa3bc53bdc48613619b9169dbe68260444ee244/nerdlets/shared/images/vmwareIcon.png -------------------------------------------------------------------------------- /nerdlets/shared/images/workloads.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/newrelic/nr1-cloud-optimize/baa3bc53bdc48613619b9169dbe68260444ee244/nerdlets/shared/images/workloads.png -------------------------------------------------------------------------------- /nerdlets/shared/ui/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Icon } from 'semantic-ui-react'; 3 | 4 | export const toastMsg = (msg, icon, loading) => ( 5 | <> 6 | 7 |   8 | {msg} 9 | 10 | ); 11 | -------------------------------------------------------------------------------- /nerdlets/shared/utils.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import AwsIcon from './images/awsIcon2.png'; 3 | 4 | export const generateFakeName = () => { 5 | let text = ''; 6 | const possible = 7 | 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 8 | 9 | for (let i = 0; i < 10; i++) 10 | text += possible.charAt(Math.floor(Math.random() * possible.length)); 11 | 12 | return text; 13 | }; 14 | 15 | export const numberWithCommas = (x, round) => { 16 | // eslint-disable-next-line 17 | Number.prototype.round = function(places) { 18 | return +`${Math.round(`${this}e+${places}`)}e-${places}`; 19 | }; 20 | 21 | return x 22 | .round(round || 2) 23 | .toString() 24 | .replace(/\B(? { 28 | if (provider === 'AWS') { 29 | return {provider}; 30 | } 31 | }; 32 | 33 | export const pickWorkloadColor = value => { 34 | if (value >= 0 && value < 20) { 35 | return { costColor: 'rgba(13, 54, 196, 0.1)', costFontColor: '#293338' }; 36 | } else if (value >= 20 && value < 40) { 37 | return { costColor: 'rgba(13, 54, 196, 0.3)', costFontColor: '#293338' }; 38 | } else if (value >= 40 && value < 60) { 39 | return { costColor: 'rgba(13, 54, 196, 0.6)', costFontColor: '#FAFBFB' }; 40 | } else if (value >= 60 && value < 80) { 41 | return { costColor: 'rgba(13, 54, 196, 0.8)', costFontColor: '#FAFBFB' }; 42 | } else if (value >= 80) { 43 | return { costColor: '#0D36C4', costFontColor: '#FAFBFB' }; 44 | } 45 | }; 46 | 47 | export const pickServiceColor = value => { 48 | if (value >= 0 && value < 20) { 49 | return { costColor: 'rgba(107, 37, 196, 0.1)', costFontColor: '#293338' }; 50 | } else if (value >= 20 && value < 40) { 51 | return { costColor: 'rgba(107, 37, 196, 0.3)', costFontColor: '#293338' }; 52 | } else if (value >= 40 && value < 60) { 53 | return { costColor: 'rgba(107, 37, 196, 0.6)', costFontColor: '#FAFBFB' }; 54 | } else if (value >= 60 && value < 80) { 55 | return { costColor: 'rgba(107, 37, 196, 0.8)', costFontColor: '#FAFBFB' }; 56 | } else if (value >= 80) { 57 | return { costColor: '#6B25C4', costFontColor: '#FAFBFB' }; 58 | } 59 | }; 60 | -------------------------------------------------------------------------------- /nerdlets/suggestions-configuration/nr1.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaType": "NERDLET", 3 | "id": "suggestions-configuration-nerdlet", 4 | "displayName": "Suggestions Configuration", 5 | "description": "" 6 | } 7 | -------------------------------------------------------------------------------- /nerdlets/suggestions-configuration/options.js: -------------------------------------------------------------------------------- 1 | exports.options = [ 2 | { 3 | type: 'HOST', 4 | suggestionsConfig: { 5 | lowCPU: { 6 | label: 'Low CPU', 7 | description: '', 8 | message: '', 9 | response: 'downsize', 10 | type: 'number', 11 | defaultValue: 5, 12 | operator: 'below', 13 | getValue: data => data?.SystemSample?.['max.cpuPercent'] 14 | }, 15 | lowMemory: { 16 | label: 'Low Memory', 17 | description: '', 18 | message: '', 19 | response: 'downsize', 20 | type: 'number', 21 | defaultValue: 5, 22 | operator: 'below', 23 | getValue: data => data?.SystemSample?.['max.memoryPercent'] 24 | }, 25 | highCPU: { 26 | label: 'High CPU', 27 | description: '', 28 | message: '', 29 | response: 'upsize', 30 | type: 'number', 31 | defaultValue: 90, 32 | operator: 'above', 33 | getValue: data => data?.SystemSample?.['max.cpuPercent'] 34 | }, 35 | highMemory: { 36 | label: 'High Memory', 37 | description: '', 38 | message: '', 39 | response: 'upsize', 40 | type: 'number', 41 | defaultValue: 90, 42 | operator: 'above', 43 | getValue: data => data?.SystemSample?.['max.memoryPercent'] 44 | } 45 | } 46 | }, 47 | { 48 | type: 'AWSRDSDBINSTANCE', 49 | suggestionsConfig: { 50 | highCPU: { 51 | label: 'High CPU', 52 | description: '', 53 | message: '', 54 | response: 'upsize', 55 | type: 'number', 56 | defaultValue: 90, 57 | operator: 'above', 58 | getValue: data => 59 | data?.DatastoreSample?.['max.provider.cpuUtilization.Maximum'] 60 | }, 61 | highMemory: { 62 | label: 'High Memory', 63 | description: '', 64 | message: '', 65 | response: 'upsize', 66 | type: 'number', 67 | defaultValue: 90, 68 | operator: 'above', 69 | getValue: data => data?.DatastoreSample?.memoryUsage 70 | } 71 | } 72 | } 73 | ]; 74 | -------------------------------------------------------------------------------- /nerdlets/suggestions-configuration/styles.scss: -------------------------------------------------------------------------------- 1 | .my-nerdlet { 2 | outline: 1px solid black; 3 | } 4 | 5 | .configForm label { 6 | width: 350px !important; 7 | } 8 | 9 | .configForm input { 10 | width: 300px !important; 11 | } 12 | -------------------------------------------------------------------------------- /nr-labs-components/NRLabsFilterBar/close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /nr-labs-components/NRLabsFilterBar/conjunction.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const Conjunction = ({ operator, isHint, onChange }) => { 5 | const [showPicker, setShowPicker] = useState(false); 6 | const thisComponent = useRef(); 7 | 8 | useEffect(() => { 9 | function handleClicksOutsideComponent(evt) { 10 | if ( 11 | showPicker && 12 | thisComponent && 13 | !thisComponent?.current?.contains(evt?.target) 14 | ) 15 | setShowPicker(false); 16 | } 17 | document.addEventListener('mousedown', handleClicksOutsideComponent); 18 | 19 | return function cleanup() { 20 | document.removeEventListener('mousedown', handleClicksOutsideComponent); 21 | }; 22 | }); 23 | 24 | const clickHandler = evt => { 25 | evt.preventDefault(); 26 | evt.stopPropagation(); 27 | setShowPicker(!showPicker); 28 | }; 29 | 30 | const changeHandler = (selection, evt) => { 31 | evt.preventDefault(); 32 | evt.stopPropagation(); 33 | if (onChange && selection !== operator) onChange(selection); 34 | }; 35 | 36 | const options = ['AND', 'OR']; 37 | 38 | return ( 39 | 44 | {operator} 45 | {showPicker && ( 46 | 47 | {options.map((opt, i) => ( 48 | changeHandler(opt, evt)} 52 | > 53 | {opt} 54 | 55 | ))} 56 | 57 | )} 58 | 59 | ); 60 | }; 61 | 62 | Conjunction.propTypes = { 63 | operator: PropTypes.string, 64 | isHint: PropTypes.bool, 65 | onChange: PropTypes.func 66 | }; 67 | 68 | export default Conjunction; 69 | -------------------------------------------------------------------------------- /nr-labs-components/NRLabsFilterBar/equal.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /nr-labs-components/NRLabsFilterBar/filter.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /nr-labs-components/NRLabsFilterBar/label.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import RemoveIcon from './remove.svg'; 5 | 6 | const Label = ({ value, onRemove }) => { 7 | const removeClickHandler = evt => { 8 | evt.stopPropagation(); 9 | if (onRemove) onRemove(evt); 10 | }; 11 | 12 | return ( 13 | 14 | {value} 15 | 19 | remove 20 | 21 | 22 | ); 23 | }; 24 | 25 | Label.propTypes = { 26 | value: PropTypes.string, 27 | onRemove: PropTypes.func 28 | }; 29 | 30 | export default Label; 31 | -------------------------------------------------------------------------------- /nr-labs-components/NRLabsFilterBar/not-equal.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /nr-labs-components/NRLabsFilterBar/open.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /nr-labs-components/NRLabsFilterBar/remove.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /nr-labs-components/NRLabsFilterBar/search.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /nr-labs-components/NRLabsFilterBar/value.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const Value = ({ value, width, optionIndex, valueIndex, onChange }) => { 5 | const changeHandler = () => 6 | onChange ? onChange(optionIndex, valueIndex) : null; 7 | 8 | return ( 9 |
10 | 16 | 19 |
20 | ); 21 | }; 22 | 23 | Value.propTypes = { 24 | value: PropTypes.object, 25 | width: PropTypes.any, // eslint-disable-line react/forbid-prop-types 26 | optionIndex: PropTypes.number, 27 | valueIndex: PropTypes.number, 28 | onChange: PropTypes.func 29 | }; 30 | 31 | export default Value; 32 | -------------------------------------------------------------------------------- /nr-labs-components/index.js: -------------------------------------------------------------------------------- 1 | export { default as MultiSelect } from './MultiSelect'; 2 | -------------------------------------------------------------------------------- /nr1.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaType": "NERDPACK", 3 | "id": "7e874b6e-c010-4933-a15d-ea5b2e54d029", 4 | "displayName": "Cloud Optimize", 5 | "description": "Identify right-sizing opportunities and potential savings of your AWS, GCP, Azure & Alibaba instances across your cloud environment" 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "nr1-cloud-optimize", 4 | "description": "Nerdpack cloud-optimize", 5 | "version": "3.42.0", 6 | "scripts": { 7 | "start": "nr1 nerdpack:serve", 8 | "test": "exit 0", 9 | "eslint-check": "eslint nerdlets/", 10 | "eslint-fix": "eslint nerdlets/ --fix" 11 | }, 12 | "ids": { 13 | "o": "39d3665c-8670-4f9f-aa79-1a18f23d3960", 14 | "dv2": "ec734258-b5ca-41b2-84ef-d97352e0ea19", 15 | "tls": "0197dff1-7829-4663-b21e-196eafbe2c33", 16 | "xps": "e84e5818-7009-4b76-af86-105e1f6abb87", 17 | "md": "133e5293-34d4-4f77-9be8-ae096e4b9936" 18 | }, 19 | "bugs": { 20 | "email": "opensource@newrelic.com" 21 | }, 22 | "dependencies": { 23 | "@mantine/hooks": "^5.9.4", 24 | "async": "^3.2.0", 25 | "graphql": "^14.7.0", 26 | "graphql-tag": "^2.10.4", 27 | "js-base64": "^2.6.0", 28 | "lodash": "^4.17.21", 29 | "prop-types": "^15.6.2", 30 | "react-json-to-csv": "^1.0.4", 31 | "react-modal": "^3.14.4", 32 | "react-select": "^3.1.1", 33 | "react-toastify": "^5.5.0", 34 | "semantic-ui-react": "^0.88.2", 35 | "semver": "^7.3.4", 36 | "uuid": "^8.3.2" 37 | }, 38 | "browserslist": [ 39 | "last 2 versions", 40 | "not ie < 11", 41 | "not dead" 42 | ], 43 | "devDependencies": { 44 | "@newrelic/eslint-plugin-newrelic": "^0.3.1", 45 | "@semantic-release/changelog": "^5.0.1", 46 | "@semantic-release/git": "^9.0.0", 47 | "eslint": "^6.8.0", 48 | "prettier": "^1.19.1", 49 | "react": "^17.0.2", 50 | "react-dom": "^17.0.2" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /pricing-pollers/azure/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cloud-optimize-pricing-poller", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "@aws-sdk/client-s3": "^3.319.0", 13 | "async": "^3.2.4", 14 | "lodash": "^4.17.21", 15 | "pino": "^8.11.0" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /pricing-pollers/azure/serverless.yml: -------------------------------------------------------------------------------- 1 | service: cloud-optimize-pricing-azure 2 | 3 | provider: 4 | name: aws 5 | runtime: nodejs18.x 6 | region: ap-southeast-2 7 | iamRoleStatements: 8 | - Effect: "Allow" 9 | Action: 10 | - "s3:List*" 11 | - "s3:Get*" 12 | - "s3:PutObject*" 13 | Resource: "arn:aws:s3:::nr1-cloud-optimize*" 14 | 15 | functions: 16 | vm: 17 | name: cloud-optimize-vm-pricing-collector 18 | memorySize: 2560 19 | timeout: 180 20 | handler: vm.handler 21 | events: 22 | - schedule: rate(12 hours) -------------------------------------------------------------------------------- /pricing-pollers/azure/utils.js: -------------------------------------------------------------------------------- 1 | const pino = require('pino')(); 2 | 3 | const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3'); 4 | 5 | const client = new S3Client({ region: 'ap-southeast-2' }); 6 | 7 | const bucket = 'nr1-cloud-optimize'; 8 | 9 | module.exports.writeToS3 = (product, key, data) => { 10 | pino.info({ 11 | event: `cloud-optimize-${product}-pricing-collector:writing-to-s3:${key}` 12 | }); 13 | 14 | const params = { 15 | Body: JSON.stringify(data).toString('binary'), 16 | Bucket: bucket, 17 | Key: `${key}.json`, 18 | ACL: 'public-read', 19 | ContentType: 'application/json' 20 | }; 21 | 22 | const command = new PutObjectCommand(params); 23 | 24 | return client.send(command); 25 | }; 26 | -------------------------------------------------------------------------------- /pricing-pollers/azure/vm.js: -------------------------------------------------------------------------------- 1 | const pino = require('pino')(); 2 | const async = require('async'); 3 | 4 | const product = 'virtual-machines'; 5 | 6 | const regionsURL = 7 | 'https://azure.microsoft.com/api/v2/pricing/calculator/regions/'; 8 | 9 | const detailsURL = os => 10 | `https://azure.microsoft.com/api/v3/pricing/virtual-machines/page/details/${os}/?showLowPriorityOffers=false`; 11 | 12 | const baseURL = 13 | 'https://azure.microsoft.com/api/v3/pricing/virtual-machines/page'; 14 | const operatingSystems = ['windows', 'linux']; 15 | 16 | const { writeToS3 } = require('./utils'); 17 | 18 | const run = async () => { 19 | // fetch vm attributes 20 | const vmDetailPromises = operatingSystems.map(os => fetch(detailsURL(os))); 21 | const vmDetailsResp = await Promise.all(vmDetailPromises); 22 | const vmDetailsData = await Promise.all(vmDetailsResp.map(p => p.json())); 23 | vmDetailsData[0] = vmDetailsData[0].attributesByOffer; 24 | vmDetailsData[1] = vmDetailsData[1].attributesByOffer; 25 | 26 | // fetch latest regions 27 | const regionResponse = await fetch(regionsURL); 28 | const regionData = await regionResponse.json(); 29 | const productsByRegion = {}; 30 | 31 | // get details for each region 32 | const q = async.queue((task, callback) => { 33 | const regionKey = task.displayName.replace(/ /g, '').toLowerCase(); 34 | if (!productsByRegion[regionKey]) { 35 | productsByRegion[regionKey] = []; 36 | } 37 | const url = `${baseURL}/${task.os}/${task.slug}`; 38 | 39 | fetch(url).then(async resp => { 40 | const json = await resp.json(); 41 | Object.keys(json).forEach(key => { 42 | const details = vmDetailsData[task.os === 'linux' ? 1 : 0][key]; 43 | const data = { 44 | category: details.category, 45 | type: key, 46 | onDemandPrice: json[key].perhour, 47 | spotPrice: [], 48 | cpusPerVm: details.cores, 49 | memPerVm: details.ram, 50 | attributes: { 51 | series: details.series, 52 | nameLocKey: details.nameLocKey, 53 | instanceName: details.instanceName, 54 | tier: details.tier, 55 | diskSize: details.diskSize, 56 | type: details.type, 57 | isVcpu: details.isVcpu 58 | } 59 | }; 60 | 61 | if (json[key].perhourspot) { 62 | data.spotPrice.push({ 63 | zone: task.slug, 64 | price: json[key].perhourspot 65 | }); 66 | } 67 | 68 | productsByRegion[regionKey].push(data); 69 | }); 70 | 71 | callback(); 72 | }); 73 | }, 5); 74 | 75 | const regionJSON = []; 76 | 77 | // each location can have multiple regions 78 | regionData.forEach(rd => { 79 | const { regions } = rd; 80 | 81 | regions.forEach(region => { 82 | regionJSON.push({ id: region.slug, name: region.displayName }); 83 | 84 | q.push([ 85 | { ...region, os: 'windows' }, 86 | { ...region, os: 'linux' } 87 | ]); 88 | }); 89 | }); 90 | 91 | await q.drain(); 92 | 93 | // write region JSON 94 | const regionJsonKey = `azure/regions`; 95 | await writeToS3(product, regionJsonKey, regionJSON); 96 | 97 | // write region data and instance data 98 | const regionWritePromises = Object.keys(productsByRegion).map(key => 99 | writeToS3(product, `azure/compute/pricing/${key}`, { 100 | products: productsByRegion[key] 101 | }) 102 | ); 103 | 104 | await Promise.all(regionWritePromises); 105 | }; 106 | 107 | // eslint-disable-next-line 108 | module.exports.handler = (event, context, callback) => { 109 | pino.info({ event: `cloud-optimize-${product}-pricing-collector:start` }); 110 | 111 | run().then(() => { 112 | pino.info({ 113 | event: `cloud-optimize-${product}-pricing-collector:finished` 114 | }); 115 | }); 116 | }; 117 | -------------------------------------------------------------------------------- /screenshots/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/newrelic/nr1-cloud-optimize/baa3bc53bdc48613619b9169dbe68260444ee244/screenshots/.gitkeep -------------------------------------------------------------------------------- /screenshots/screenshot_01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/newrelic/nr1-cloud-optimize/baa3bc53bdc48613619b9169dbe68260444ee244/screenshots/screenshot_01.png -------------------------------------------------------------------------------- /screenshots/screenshot_02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/newrelic/nr1-cloud-optimize/baa3bc53bdc48613619b9169dbe68260444ee244/screenshots/screenshot_02.png -------------------------------------------------------------------------------- /screenshots/screenshot_03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/newrelic/nr1-cloud-optimize/baa3bc53bdc48613619b9169dbe68260444ee244/screenshots/screenshot_03.png --------------------------------------------------------------------------------