├── .github └── workflows │ ├── nextjs_bundle_analysis.yml │ └── schedule.yml ├── .gitignore ├── .golangci.yaml ├── .pre-commit-config.yaml ├── .prettierrc ├── .vscode ├── extensions.json └── settings.json ├── CODEOWNERS ├── LICENSE ├── README.md ├── client ├── apiExample │ ├── aliyun.json │ ├── aws.json │ └── gcp.json ├── aws │ ├── aws.go │ ├── aws_mock.go │ ├── aws_test.go │ └── region-code.json ├── client.go └── gcp │ ├── gcp.go │ └── gcp_test.go ├── data ├── dbInstance.json └── sample.json ├── frontend ├── .eslintrc.json ├── .gitignore ├── .npmrc ├── components │ ├── ButtonGroup.tsx │ ├── CompareBadgeGroup.tsx │ ├── CompareMenu.tsx │ ├── CompareTable.tsx │ ├── Footer.tsx │ ├── Header.tsx │ ├── Icon.tsx │ ├── LineChart.tsx │ ├── RegionMenu.tsx │ ├── RegionPricingTable.tsx │ ├── RelatedTable.tsx │ ├── SearchMenu.tsx │ ├── TdCell.tsx │ └── primitives │ │ └── Tooltip.tsx ├── layouts │ └── main.tsx ├── next-sitemap.config.js ├── next.config.js ├── package.json ├── pages │ ├── _app.tsx │ ├── _document.tsx │ ├── compare │ │ └── [comparison].tsx │ ├── index.tsx │ ├── instance │ │ └── [instance].tsx │ ├── provider │ │ └── [provider] │ │ │ ├── engine │ │ │ └── [engine].tsx │ │ │ └── index.tsx │ └── region │ │ └── [region].tsx ├── pnpm-lock.yaml ├── postcss.config.js ├── public │ ├── .gitignore │ ├── favicon.ico │ ├── fonts │ │ └── xkcd.ttf │ ├── icons │ │ ├── bytebase-cncf.svg │ │ ├── db-mysql.png │ │ ├── db-oracle.png │ │ ├── db-postgres.png │ │ ├── db-sqlserver.png │ │ ├── dbcost-logo-full.webp │ │ ├── provider-aws.png │ │ └── provider-gcp.png │ ├── mysql-vs-pg.webp │ ├── sqlchat.webp │ └── star-history.webp ├── stores │ ├── dbInstanceContext.tsx │ ├── index.ts │ └── searchConfigContext.tsx ├── styles │ └── globals.css ├── tailwind.config.js ├── tsconfig.json ├── types │ ├── common │ │ ├── chart.ts │ │ ├── common.ts │ │ ├── id.ts │ │ └── index.ts │ ├── dbInstance.ts │ ├── index.ts │ ├── region.ts │ ├── route.ts │ ├── searchConfig.ts │ ├── table.ts │ └── term.ts └── utils │ ├── assets.ts │ ├── compare.ts │ ├── config.ts │ ├── index.ts │ ├── instance.ts │ ├── price.ts │ ├── region.ts │ └── table.ts ├── go.mod ├── go.sum ├── seed └── main.go └── store ├── common.go ├── contributor.go ├── db_instance.go └── db_instance_test.go /.github/workflows/nextjs_bundle_analysis.yml: -------------------------------------------------------------------------------- 1 | name: "Next.js Bundle Analysis" 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main # change this if your default branch is named differently 8 | workflow_dispatch: 9 | 10 | defaults: 11 | run: 12 | # change this if your nextjs app does not live at the root of the repo 13 | working-directory: ./frontend 14 | 15 | jobs: 16 | analyze: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - name: Set up pnpm 22 | uses: pnpm/action-setup@v3.0.0 23 | with: 24 | version: 6.10.0 25 | 26 | - name: Set up node 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version: "14.x" 30 | cache: pnpm 31 | cache-dependency-path: "frontend/pnpm-lock.yaml" 32 | 33 | - name: Install dependencies 34 | run: pnpm install --frozen-lockfile 35 | 36 | - name: Restore next build 37 | uses: actions/cache@v4 38 | id: restore-build-cache 39 | env: 40 | cache-name: cache-next-build 41 | with: 42 | # if you use a custom build directory, replace all instances of `.next` in this file with your build directory 43 | # ex: if your app builds to `dist`, replace `.next` with `dist` 44 | path: .next/cache 45 | # change this if you prefer a more strict cache 46 | key: ${{ runner.os }}-build-${{ env.cache-name }} 47 | 48 | - name: Build next.js app 49 | # change this if your site requires a custom build command 50 | run: ./node_modules/.bin/next build 51 | 52 | # Here's the first place where next-bundle-analysis' own script is used 53 | # This step pulls the raw bundle stats for the current bundle 54 | - name: Analyze bundle 55 | run: npx -p nextjs-bundle-analysis report 56 | 57 | - name: Upload bundle 58 | uses: actions/upload-artifact@v4 59 | with: 60 | name: bundle 61 | path: ./frontend/.next/analyze/__bundle_analysis.json 62 | 63 | - name: Download base branch bundle stats 64 | uses: dawidd6/action-download-artifact@v3.1.4 65 | if: success() && github.event.number 66 | with: 67 | workflow: nextjs_bundle_analysis.yml 68 | branch: ${{ github.event.pull_request.base.ref }} 69 | path: ./frontend/.next/analyze/base 70 | 71 | # And here's the second place - this runs after we have both the current and 72 | # base branch bundle stats, and will compare them to determine what changed. 73 | # There are two configurable arguments that come from package.json: 74 | # 75 | # - budget: optional, set a budget (bytes) against which size changes are measured 76 | # it's set to 350kb here by default, as informed by the following piece: 77 | # https://infrequently.org/2021/03/the-performance-inequality-gap/ 78 | # 79 | # - red-status-percentage: sets the percent size increase where you get a red 80 | # status indicator, defaults to 20% 81 | # 82 | # Either of these arguments can be changed or removed by editing the `nextBundleAnalysis` 83 | # entry in your package.json file. 84 | - name: Compare with base branch bundle 85 | if: success() && github.event.number 86 | run: ls -laR .next/analyze/base && npx -p nextjs-bundle-analysis compare 87 | 88 | - name: Get comment body 89 | id: get-comment-body 90 | if: success() && github.event.number 91 | run: | 92 | body=$(cat .next/analyze/__bundle_analysis_comment.txt) 93 | body="${body//'%'/'%25'}" 94 | body="${body//$'\n'/'%0A'}" 95 | body="${body//$'\r'/'%0D'}" 96 | echo ::set-output name=body::$body 97 | 98 | - name: Find Comment 99 | uses: peter-evans/find-comment@v3 100 | if: success() && github.event.number 101 | id: fc 102 | with: 103 | issue-number: ${{ github.event.number }} 104 | body-includes: "" 105 | 106 | - name: Create Comment 107 | uses: peter-evans/create-or-update-comment@v4.0.0 108 | if: success() && github.event.number && steps.fc.outputs.comment-id == 0 109 | with: 110 | issue-number: ${{ github.event.number }} 111 | body: ${{ steps.get-comment-body.outputs.body }} 112 | 113 | - name: Update Comment 114 | uses: peter-evans/create-or-update-comment@v4.0.0 115 | if: success() && github.event.number && steps.fc.outputs.comment-id != 0 116 | with: 117 | issue-number: ${{ github.event.number }} 118 | body: ${{ steps.get-comment-body.outputs.body }} 119 | comment-id: ${{ steps.fc.outputs.comment-id }} 120 | edit-mode: replace 121 | -------------------------------------------------------------------------------- /.github/workflows/schedule.yml: -------------------------------------------------------------------------------- 1 | name: Update pricing data 2 | 3 | # run this action every day 4 | on: 5 | workflow_dispatch: 6 | # schedule: 7 | # - cron: "0 1 * * *" 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | env: 13 | TZ: Asia/Shanghai 14 | 15 | steps: 16 | - name: Clone repository 17 | uses: actions/checkout@v4 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v5 21 | with: 22 | go-version: 1.18 23 | 24 | - name: Fetch latest pricing data 25 | run: | 26 | export API_KEY_GCP=${{ secrets.API_KEY_GCP }} 27 | go run seed/main.go 28 | 29 | - name: Create pull request 30 | uses: peter-evans/create-pull-request@v6 31 | with: 32 | commit-message: "chore: update pricing data" 33 | author: GitHub 34 | branch: update-pricing-data 35 | delete-branch: true 36 | title: "chore: update pricing data" 37 | body: | 38 | This PR means that our GitHub Action CronJob has detected an update to the pricing data. 39 | This is a fairly rare event, so if anyone sees this PR, please check the results of the actions run first. 40 | Before merging this PR, make sure the actions logs are as expected. 41 | labels: | 42 | data update 43 | automated 44 | draft: false 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | !.vscode/settings.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | 27 | # unplugin by vite 28 | components.d.ts 29 | 30 | *.log* 31 | .nuxt 32 | .nitro 33 | .cache 34 | .output 35 | .env 36 | dist 37 | .vercel 38 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | linters: 2 | enable: 3 | - errcheck 4 | - goimports 5 | - golint 6 | - govet 7 | - staticcheck 8 | - misspell 9 | 10 | # golangci-lint run --exclude="Rollback,logger.Sync" 11 | issues: 12 | exclude: 13 | - Rollback 14 | - logger.Sync 15 | 16 | run: 17 | timeout: 5m 18 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/mirrors-eslint 3 | rev: v8.3.0 4 | hooks: 5 | - id: eslint 6 | files: frontend/.+\.([jt]sx?|vue)$ 7 | types: [file] 8 | args: [--fix] 9 | - repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook 10 | rev: v2.2.0 11 | hooks: 12 | - id: commitlint 13 | stages: [commit-msg] 14 | additional_dependencies: ["@commitlint/config-conventional"] 15 | - repo: https://github.com/pre-commit/pre-commit-hooks 16 | rev: v2.3.0 17 | hooks: 18 | - id: check-yaml 19 | - id: end-of-file-fixer 20 | - id: trailing-whitespace 21 | - repo: https://github.com/golangci/golangci-lint 22 | rev: v1.44.0 23 | hooks: 24 | - id: golangci-lint 25 | - repo: https://github.com/pre-commit/mirrors-prettier 26 | rev: "v2.4.1" 27 | hooks: 28 | - id: prettier 29 | files: frontend/src/locales/.+\.yaml$ 30 | types_or: [yaml] 31 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": false, 3 | "tabWidth": 2, 4 | "singleQuote": false, 5 | "trailingComma": "es5", 6 | "printWidth": 80 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "antfu.iconify", 5 | "esbenp.prettier-vscode", 6 | "bradlc.vscode-tailwindcss", 7 | "streetsidesoftware.code-spell-checker" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.validate": [ 3 | "javascript", 4 | "javascriptreact", 5 | "typescript", 6 | "typescriptreact", 7 | "vue" 8 | ], 9 | "editor.codeActionsOnSave": { 10 | "source.fixAll.eslint": "explicit" 11 | }, 12 | "files.associations": { 13 | "*.vue": "vue" 14 | }, 15 | "[javascript]": { 16 | "editor.defaultFormatter": "esbenp.prettier-vscode" 17 | }, 18 | "[typescript]": { 19 | "editor.defaultFormatter": "esbenp.prettier-vscode" 20 | }, 21 | "[json]": { 22 | "editor.defaultFormatter": "esbenp.prettier-vscode" 23 | }, 24 | "[jsonc]": { 25 | "editor.defaultFormatter": "esbenp.prettier-vscode" 26 | }, 27 | "[vue]": { 28 | "editor.defaultFormatter": "esbenp.prettier-vscode", 29 | "editor.formatOnSave": true 30 | }, 31 | "[yaml]": { 32 | "editor.defaultFormatter": "esbenp.prettier-vscode", 33 | "editor.formatOnSaveMode": "file" 34 | }, 35 | "editor.formatOnSave": true, 36 | "go.lintTool": "golint", 37 | "cSpell.words": ["Nuxt"] 38 | } 39 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # These owners will be the default owners for everything in the repo. 2 | * @LiuJi-Jim @d-bytebase 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022-present Bytebase (Hong Kong) Limited. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DB Cost 2 | 3 | [dbcost.com](https://dbcost.com) the simple pricing calculator and comparison tool for the Cloud databases. 4 | 5 | ## Roadmap 6 | 7 | - [ ] Supported Cloud Vendors 8 | - [x] AWS RDS 9 | - [x] GCP Cloud SQL 10 | - [ ] Azure 11 | - [ ] AliCloud 12 | - [ ] Cost Table 13 | - [x] Basic Table 14 | - [x] Data Refinement Menu 15 | - [ ] Table for checked Instance 16 | - [ ] RAM / CPU wise calculator special for GCP 17 | - [ ] Cost Charts 18 | - [x] Compare the difference in monthly price between different offers ( Line Chart ) 19 | - [ ] Compare the difference in total price between different offers ( Stacked Columns Chart ) 20 | - [ ] Comparison page 21 | - [ ] SEO with **Next** 22 | - [x] Semantic URLs 23 | - [x] Related instances/regions references 24 | - [ ] Maintaining Relevant Services 25 | - [ ] Incorporate Terraform 26 | - [ ] Database Service Life Cycle Management 27 | - [ ] Database Benchmark 28 | - [ ] Benchmark Test Scheduling / Result Storage 29 | - [ ] Benchmark Dashboard 30 | 31 | ## Background 32 | 33 | The market lacks a tool for developers to compare different database products before making a final decision. A site, where all available cloud providers' database performance and cost can be demonstrated, is desired. 34 | 35 | ## Tech Stack (at least this is expected) 36 | 37 | ### Used 38 | 39 | 40 | 41 | - **Golang**. 42 | - **Next.js** with **React 18**. 43 | - **Ant Design** as component library. 44 | - **Nivo** for chart visualization. 45 | - **Cron** task by **GitHub Actions**. 46 | 47 | ### Requirement 48 | 49 | - [Go v1.7](https://go.dev/dl/) 50 | - [pnpm](https://pnpm.io) for package management 51 | 52 | ## How to start? 53 | 54 | This project is under development and is very unstable. The way to start this project may improve as process goes on. 55 | 56 | ### Fetching Data 57 | 58 | We maintain our data through a GitHub Actions CronJob. It runs every day to make sure the pricing data is up to date. The data on dbcost.com is provided at [here](https://github.com/bytebase/dbcost/blob/main/data/dbInstance.json). 59 | 60 | ### Installing Frontend Dependencies 61 | 62 | ``` 63 | cd ./frontend && pnpm install 64 | ``` 65 | 66 | ### Starting the Frontend 67 | 68 | ``` 69 | pnpm dev 70 | ``` 71 | 72 | Now dbcost is available at [localhost:3000](localhost:3000) 73 | 74 | ### Seeding data manually 75 | 76 | If you would like to fetch the latest data manually, please apply for a [GCP API KEY](https://cloud.google.com/apigee/docs/api-platform/security/api-keys) with access to the [Cloud Billing API](https://cloud.google.com/billing/docs/reference/rest) first. For AWS, the API is open to everyone, you do not need a API KEY to access relevant resource. 77 | 78 | First set environment variable: 79 | 80 | ``` 81 | export API_KEY_GCP={YOUR_API_KEY} 82 | ``` 83 | 84 | Then run the following command: 85 | 86 | ``` 87 | go run ./seed/main/go 88 | ``` 89 | -------------------------------------------------------------------------------- /client/apiExample/aliyun.json: -------------------------------------------------------------------------------- 1 | { 2 | "url": "// https://rds-buy.aliyun.com/buy/describeClassList.json?OrderType=BUY&CommodityCode=rds&dBInstanceId=&RegionId=us-east-1", 3 | "code": "200", 4 | "data": { 5 | "Items": [ 6 | { 7 | "ClassCode": "pg.n2.2c.1m", 8 | "ClassGroup": "通用型(新)", 9 | "Cpu": "2 ", 10 | "MaxConnections": "400", 11 | "MaxIOMBPS": "N/A", 12 | "MaxIOPS": "N/A", 13 | "MemoryClass": " 4G(通用型新)", 14 | "ReferencePrice": "4320" 15 | } 16 | ], 17 | "RegionId": "cn-hongkong", 18 | "RequestId": "84DB3D60-B186-5991-BA20-0ADCA6C36B8F" 19 | }, 20 | "requestId": "84DB3D60-B186-5991-BA20-0ADCA6C36B8F", 21 | "successResponse": true 22 | } 23 | -------------------------------------------------------------------------------- /client/apiExample/gcp.json: -------------------------------------------------------------------------------- 1 | { 2 | "skus": [ 3 | { 4 | "name": "services/9662-B51E-5089/skus/0009-6F35-3126", 5 | "skuId": "0009-6F35-3126", 6 | "description": "Network Internet Egress from EMEA to Seoul", 7 | "category": { 8 | "serviceDisplayName": "Cloud SQL", 9 | "resourceFamily": "Network", 10 | "resourceGroup": "PremiumInternetEgress", 11 | "usageType": "OnDemand" 12 | }, 13 | "serviceRegions": ["europe-west1"], 14 | "pricingInfo": [ 15 | { 16 | "summary": "", 17 | "pricingExpression": { 18 | "usageUnit": "GiBy", 19 | "displayQuantity": 1, 20 | "tieredRates": [ 21 | { 22 | "startUsageAmount": 0, 23 | "unitPrice": { 24 | "currencyCode": "USD", 25 | "units": "0", 26 | "nanos": 190000000 27 | } 28 | } 29 | ], 30 | "usageUnitDescription": "gibibyte", 31 | "baseUnit": "By", 32 | "baseUnitDescription": "byte", 33 | "baseUnitConversionFactor": 1073741824 34 | }, 35 | "currencyConversionRate": 1, 36 | "effectiveTime": "2022-04-18T01:08:21.806Z" 37 | } 38 | ], 39 | "serviceProviderName": "Google" 40 | }, 41 | { 42 | "name": "services/9662-B51E-5089/skus/000E-8560-3D8D", 43 | "skuId": "000E-8560-3D8D", 44 | "description": "Cloud SQL for MySQL: Zonal - 96 vCPU + 360GB RAM in Paris", 45 | "category": { 46 | "serviceDisplayName": "Cloud SQL", 47 | "resourceFamily": "ApplicationServices", 48 | "resourceGroup": "SQLGen2InstancesN1Standard", 49 | "usageType": "OnDemand" 50 | }, 51 | "serviceRegions": ["europe-west9"], 52 | "pricingInfo": [ 53 | { 54 | "summary": "", 55 | "pricingExpression": { 56 | "usageUnit": "h", 57 | "displayQuantity": 1, 58 | "tieredRates": [ 59 | { 60 | "startUsageAmount": 0, 61 | "unitPrice": { 62 | "currencyCode": "USD", 63 | "units": "7", 64 | "nanos": 523600000 65 | } 66 | } 67 | ], 68 | "usageUnitDescription": "hour", 69 | "baseUnit": "s", 70 | "baseUnitDescription": "second", 71 | "baseUnitConversionFactor": 3600 72 | }, 73 | "currencyConversionRate": 1, 74 | "effectiveTime": "2022-04-18T01:08:21.806Z" 75 | } 76 | ], 77 | "serviceProviderName": "Google" 78 | }, 79 | { 80 | "name": "services/9662-B51E-5089/skus/002A-FBB4-3C32", 81 | "skuId": "002A-FBB4-3C32", 82 | "description": "Cloud SQL for PostgreSQL: Zonal - vCPU in Netherlands", 83 | "category": { 84 | "serviceDisplayName": "Cloud SQL", 85 | "resourceFamily": "ApplicationServices", 86 | "resourceGroup": "SQLGen2InstancesCPU", 87 | "usageType": "OnDemand" 88 | }, 89 | "serviceRegions": ["europe-west4"], 90 | "pricingInfo": [ 91 | { 92 | "summary": "", 93 | "pricingExpression": { 94 | "usageUnit": "h", 95 | "displayQuantity": 1, 96 | "tieredRates": [ 97 | { 98 | "startUsageAmount": 0, 99 | "unitPrice": { 100 | "currencyCode": "USD", 101 | "units": "0", 102 | "nanos": 45400000 103 | } 104 | } 105 | ], 106 | "usageUnitDescription": "hour", 107 | "baseUnit": "s", 108 | "baseUnitDescription": "second", 109 | "baseUnitConversionFactor": 3600 110 | }, 111 | "currencyConversionRate": 1, 112 | "effectiveTime": "2022-04-18T01:08:21.806Z" 113 | } 114 | ], 115 | "serviceProviderName": "Google", 116 | "geoTaxonomy": { 117 | "type": "REGIONAL", 118 | "regions": ["europe-west4"] 119 | } 120 | }, 121 | { 122 | "name": "services/9662-B51E-5089/skus/0033-DF11-C9B7", 123 | "skuId": "0033-DF11-C9B7", 124 | "description": "Cloud SQL for SQL Server: Regional - RAM in APAC", 125 | "category": { 126 | "serviceDisplayName": "Cloud SQL", 127 | "resourceFamily": "ApplicationServices", 128 | "resourceGroup": "SQLGen2InstancesRAM", 129 | "usageType": "OnDemand" 130 | }, 131 | "serviceRegions": ["asia-east1"], 132 | "pricingInfo": [ 133 | { 134 | "summary": "", 135 | "pricingExpression": { 136 | "usageUnit": "GiBy.h", 137 | "displayQuantity": 1, 138 | "tieredRates": [ 139 | { 140 | "startUsageAmount": 0, 141 | "unitPrice": { 142 | "currencyCode": "USD", 143 | "units": "0", 144 | "nanos": 14000000 145 | } 146 | } 147 | ], 148 | "usageUnitDescription": "gibibyte hour", 149 | "baseUnit": "By.s", 150 | "baseUnitDescription": "byte second", 151 | "baseUnitConversionFactor": 3865470566400 152 | }, 153 | "currencyConversionRate": 1, 154 | "effectiveTime": "2022-04-18T01:08:21.806Z" 155 | } 156 | ], 157 | "serviceProviderName": "Google", 158 | "geoTaxonomy": { 159 | "type": "REGIONAL", 160 | "regions": ["asia-east1"] 161 | } 162 | }, 163 | { 164 | "name": "services/9662-B51E-5089/skus/D74A-49A5-A0F3", 165 | "skuId": "D74A-49A5-A0F3", 166 | "description": "Commitment - dollar based v1: Cloud SQL database europe-west3 for 1 year", 167 | "category": { 168 | "serviceDisplayName": "Cloud SQL", 169 | "resourceFamily": "ApplicationServices", 170 | "resourceGroup": "SQLGen1Instances", 171 | "usageType": "OnDemand" 172 | }, 173 | "serviceRegions": ["europe-west3"], 174 | "pricingInfo": [ 175 | { 176 | "summary": "", 177 | "pricingExpression": { 178 | "usageUnit": "h", 179 | "displayQuantity": 1, 180 | "tieredRates": [ 181 | { 182 | "startUsageAmount": 0, 183 | "unitPrice": { 184 | "currencyCode": "USD", 185 | "units": "0", 186 | "nanos": 7500000 187 | } 188 | } 189 | ], 190 | "usageUnitDescription": "hour", 191 | "baseUnit": "s", 192 | "baseUnitDescription": "second", 193 | "baseUnitConversionFactor": 3600 194 | }, 195 | "currencyConversionRate": 1, 196 | "effectiveTime": "2022-04-18T01:08:21.806Z" 197 | } 198 | ], 199 | "serviceProviderName": "Google", 200 | "geoTaxonomy": { 201 | "type": "REGIONAL", 202 | "regions": ["europe-west3"] 203 | } 204 | } 205 | ] 206 | } 207 | -------------------------------------------------------------------------------- /client/aws/aws.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "strconv" 10 | "strings" 11 | 12 | "github.com/bytebase/dbcost/client" 13 | ) 14 | 15 | // Client is the client struct 16 | type Client struct{} 17 | 18 | var _ client.Client = (*Client)(nil) 19 | 20 | // NewClient return a client 21 | func NewClient() *Client { 22 | return &Client{} 23 | } 24 | 25 | // pricing is the api message for AWS pricing .json file 26 | type pricing struct { 27 | Product interface{} `json:"products"` 28 | Term interface{} `json:"terms"` 29 | } 30 | 31 | // EngineType is the engine type specified in AWS api message. 32 | // we implement its String() method to convert it into the type stored in ours. 33 | type EngineType string 34 | 35 | const ( 36 | engineTypeMySQL = "MySQL" 37 | engineTypePostgreSQL = "PostgreSQL" 38 | // TODO: Add support to SQLSERVER & ORACLE 39 | // engineTypeSQLServer = "SQL Server" 40 | // engineTypeOracle = "Oracle" 41 | engineTypeUnknown = "UNKNOWN" 42 | ) 43 | 44 | func (e EngineType) String() string { 45 | switch e { 46 | case engineTypeMySQL: 47 | return "MYSQL" 48 | case engineTypePostgreSQL: 49 | return "POSTGRES" 50 | } 51 | return "UNKNOWN" 52 | } 53 | 54 | // instance is the api message of the Instance for AWS specifically 55 | type instance struct { 56 | ID string 57 | // The tag here does not follow small-camel naming style, it is intended for the AWS name it this way. 58 | ServiceCode string `json:"servicecode"` 59 | Location string `json:"location"` 60 | RegionCode string `json:"regionCode"` 61 | Type string `json:"instanceType"` 62 | InstanceFamily string `json:"instanceFamily"` 63 | // Noted that this is a api mmessage from AWS, so we still use vcpu for unmarshaling the info, 64 | // But in our systems, we use CPU over VCPU. 65 | CPU string `json:"vcpu"` 66 | Memory string `json:"memory"` 67 | PhysicalProcessor string `json:"physicalProcessor"` 68 | NetworkPerformance string `json:"networkPerformance"` 69 | DeploymentOption string `json:"deploymentOption"` 70 | DatabaseEngine EngineType `json:"databaseEngine"` 71 | } 72 | 73 | // productEntry is the entry of the instance info 74 | type productEntry struct { 75 | ID string `json:"sku"` 76 | ProductFamily string `json:"productFamily"` 77 | Attributes instance `json:"attributes"` 78 | } 79 | 80 | // instanceRecord is the Record of the instance info 81 | type instanceRecord map[string]productEntry 82 | 83 | // priceDimensionRaw is the raw dimension struct marshaled from the aws json file 84 | type priceDimensionRaw struct { 85 | Description string `json:"description"` 86 | Unit string `json:"unit"` 87 | PricePerUnit map[client.Currency]string `json:"pricePerUnit"` 88 | } 89 | 90 | // priceRaw is the raw price struct marshaled from the aws json file 91 | type priceRaw struct { 92 | Dimension map[string]priceDimensionRaw `json:"priceDimensions"` 93 | Term *client.ChargePayload `json:"termAttributes"` 94 | } 95 | 96 | // InfoEndPoint is the instance info endpoint 97 | // More infomation, see: https://docs.aws.amazon.com/awsaccountbilling/latest/aboutv2/reading-an-offer.html 98 | const InfoEndPoint = "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonRDS/current/index.json" 99 | 100 | // GetOffer returns the offers provided by AWS. 101 | func (c *Client) GetOffer() ([]*client.Offer, error) { 102 | res, err := http.Get(InfoEndPoint) 103 | if err != nil { 104 | return nil, fmt.Errorf("Fail to fetch the info file, [internal]: %v", err) 105 | } 106 | if res.StatusCode != 200 { 107 | return nil, fmt.Errorf("An http error occur , [internal]: %v", err) 108 | } 109 | 110 | data, err := io.ReadAll(res.Body) 111 | if err != nil { 112 | return nil, fmt.Errorf("Fail when reading response data , [internal]: %v", err) 113 | } 114 | 115 | rawData := &pricing{} 116 | if err := json.Unmarshal(data, rawData); err != nil { 117 | return nil, fmt.Errorf("Fail when unmarshaling response data , [internal]: %v", err) 118 | } 119 | 120 | offerList, err := extractOffer(rawData) 121 | if err != nil { 122 | return nil, fmt.Errorf("Fail when extrating offer, [internal]: %v", err) 123 | } 124 | 125 | productBytes, err := json.Marshal(rawData.Product) 126 | if err != nil { 127 | return nil, fmt.Errorf("Fail to unmarshal the result, [internal]: %v", err) 128 | } 129 | productsDecoder := json.NewDecoder(bytes.NewReader(productBytes)) 130 | var rawEntryList instanceRecord 131 | if err := productsDecoder.Decode(&rawEntryList); err != nil { 132 | return nil, fmt.Errorf("Fail to decode the result, [internal]: %v", err) 133 | } 134 | 135 | fillInstancePayload(rawEntryList, offerList) 136 | 137 | return offerList, nil 138 | } 139 | 140 | // extractOffer extracts the client.offer from the rawData. 141 | func extractOffer(rawData *pricing) ([]*client.Offer, error) { 142 | bytePrice, err := json.Marshal(rawData.Term) 143 | if err != nil { 144 | return nil, fmt.Errorf("Fail to unmarshal the result, [internal]: %v", err) 145 | } 146 | 147 | offerDecoder := json.NewDecoder(bytes.NewReader(bytePrice)) 148 | var rawEntry map[client.OfferType]map[string]map[string]priceRaw 149 | if err := offerDecoder.Decode(&rawEntry); err != nil { 150 | return nil, fmt.Errorf("Fail to decode the result, [internal]: %v", err) 151 | } 152 | 153 | var offerList []*client.Offer 154 | incrID := 0 155 | // rawEntry has two charge types, reserved and on-demand. 156 | for chargeType, instanceOfferList := range rawEntry { 157 | // we use skuID here to track the instance relevant to this offer 158 | for instanceSKU, _offerList := range instanceOfferList { 159 | for instanceTermCode, rawOffer := range _offerList { 160 | offer := &client.Offer{ 161 | ID: incrID, 162 | // e.g. 9QH3PUGXCYKNCYPB 163 | SKU: instanceSKU, 164 | // e.g. 9QH3PUGXCYKNCYPB.HU7G6KETJZ 165 | TermCode: instanceTermCode, 166 | // AWS only offer instance-wise product 167 | OfferType: client.OfferTypeInstance, 168 | ChargeType: client.ChargeType(chargeType), 169 | ChargePayload: rawOffer.Term, 170 | } 171 | // an offer may have differnet charging dimension, say upfront fee and it relevant fee charged hourly. 172 | for _, dimension := range rawOffer.Dimension { 173 | USDFloat, err := strconv.ParseFloat(dimension.PricePerUnit[client.CurrencyUSD], 64) 174 | if err != nil { 175 | return nil, fmt.Errorf("Fail to parse the price to type FLOAT64, value: %v, [internal]: %v", dimension.PricePerUnit[client.CurrencyUSD], err) 176 | } 177 | if dimension.Unit == "Quantity" { 178 | offer.CommitmentUSD = USDFloat 179 | } else { 180 | offer.HourlyUSD = USDFloat 181 | } 182 | 183 | } 184 | 185 | incrID++ 186 | offerList = append(offerList, offer) 187 | } 188 | } 189 | } 190 | 191 | return offerList, nil 192 | } 193 | 194 | func fillInstancePayload(instanceRecord instanceRecord, offerList []*client.Offer) { 195 | // There may be many offers that are bind to the same instance with the same SKU. 196 | offerMap := make(map[string][]*client.Offer) 197 | for _, offer := range offerList { 198 | offerMap[offer.SKU] = append(offerMap[offer.SKU], offer) 199 | } 200 | 201 | for instanceSKU, entry := range instanceRecord { 202 | if entry.ProductFamily != "Database Instance" /* filter non-db instance */ || 203 | entry.Attributes.DeploymentOption != "Single-AZ" /* filter multi-region deployment */ { 204 | continue 205 | } 206 | 207 | engineType := entry.Attributes.DatabaseEngine.String() 208 | if engineType == engineTypeUnknown { 209 | continue 210 | } 211 | 212 | instance := &client.OfferInstancePayload{ 213 | Type: entry.Attributes.Type, 214 | InstanceFamily: entry.Attributes.InstanceFamily, 215 | CPU: entry.Attributes.CPU, 216 | Memory: strings.ReplaceAll(entry.Attributes.Memory, "GiB", ""), 217 | PhysicalProcessor: entry.Attributes.PhysicalProcessor, 218 | NetworkPerformance: entry.Attributes.NetworkPerformance, 219 | DatabaseEngine: client.EngineType(engineType), 220 | } 221 | if _, ok := offerMap[instanceSKU]; ok { 222 | for _, offer := range offerMap[instanceSKU] { 223 | if entry.Attributes.RegionCode != "" { 224 | offer.RegionList = []string{entry.Attributes.RegionCode} 225 | } else { 226 | // When we encounter empty region code, use location string directly. 227 | offer.RegionList = []string{entry.Attributes.Location} 228 | } 229 | offer.InstancePayload = instance 230 | } 231 | } 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /client/aws/aws_mock.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/bytebase/dbcost/client" 10 | ) 11 | 12 | // MockGetOffer mock the aws client 13 | func MockGetOffer(filePath string) ([]*client.Offer, error) { 14 | file, err := os.ReadFile(filePath) 15 | 16 | rawData := &pricing{} 17 | if err = json.Unmarshal(file, rawData); err != nil { 18 | return nil, fmt.Errorf("fail to unmarshal json, internal: %v", err) 19 | } 20 | 21 | offerList, err := extractOffer(rawData) 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | byteProducts, err := json.Marshal(rawData.Product) 27 | if err != nil { 28 | return nil, fmt.Errorf("Fail to unmarshal the result, [internal]: %v", err) 29 | } 30 | productsDecoder := json.NewDecoder(bytes.NewReader(byteProducts)) 31 | var rawEntryList instanceRecord 32 | if err := productsDecoder.Decode(&rawEntryList); err != nil { 33 | return nil, fmt.Errorf("Fail to decode the result, [internal]: %v", err) 34 | } 35 | 36 | fillInstancePayload(rawEntryList, offerList) 37 | 38 | return offerList, nil 39 | } 40 | -------------------------------------------------------------------------------- /client/aws/aws_test.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "os" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func Test_Extraction(t *testing.T) { 13 | file, err := os.ReadFile("./apiExample/aws.json") 14 | require.NoError(t, err) 15 | 16 | rawData := &pricing{} 17 | err = json.Unmarshal(file, rawData) 18 | require.NoError(t, err) 19 | 20 | _, err = extractOffer(rawData) 21 | require.NoError(t, err) 22 | 23 | byteProducts, err := json.Marshal(rawData.Product) 24 | require.NoError(t, err) 25 | productsDecoder := json.NewDecoder(bytes.NewReader(byteProducts)) 26 | var rawEntryList instanceRecord 27 | err = productsDecoder.Decode(&rawEntryList) 28 | require.NoError(t, err) 29 | } 30 | 31 | func Test_HTTP(t *testing.T) { 32 | c := NewClient() 33 | _, err := c.GetOffer() 34 | require.NoError(t, err) 35 | } 36 | -------------------------------------------------------------------------------- /client/aws/region-code.json: -------------------------------------------------------------------------------- 1 | { 2 | "af-south-1": "Africa (Cape Town)", 3 | "ap-east-1": "Asia Pacific (Hong Kong)", 4 | "ap-northeast-1": "Asia Pacific (Tokyo)", 5 | "ap-northeast-2": "Asia Pacific (Seoul)", 6 | "ap-northeast-3": "Asia Pacific (Osaka)", 7 | "ap-south-1": "Asia Pacific (Mumbai)", 8 | "ap-southeast-1": "Asia Pacific (Singapore)", 9 | "ap-southeast-2": "Asia Pacific (Sydney)", 10 | "ap-southeast-3": "Asia Pacific (Jakarta)", 11 | "ca-central-1": "Canada (Central)", 12 | "eu-central-1": "Europe (Frankfurt)", 13 | "eu-north-1": "Europe (Stockholm)", 14 | "eu-south-1": "Europe (Milan)", 15 | "eu-west-1": "Europe (Ireland)", 16 | "eu-west-2": "Europe (London)", 17 | "eu-west-3": "Europe (Paris)", 18 | "me-south-1": "Middle East (Bahrain)", 19 | "sa-east-1": "South America (Sao Paulo)", 20 | "us-east-1": "US East (N. Virginia)", 21 | "us-east-2": "US East (Ohio)", 22 | "us-west-1": "US West (N. California)", 23 | "us-west-2": "US West (Oregon)" 24 | } 25 | -------------------------------------------------------------------------------- /client/client.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | // EngineType is the type of the database engine. 4 | // It should be noticed that the price of different engine may differ. 5 | type EngineType string 6 | 7 | const ( 8 | // EngineTypeMySQL is the engine type for MySQL. 9 | EngineTypeMySQL = "MYSQL" 10 | // EngineTypePostgreSQL is the engine type for PostgreSQL. 11 | EngineTypePostgreSQL = "POSTGRES" 12 | 13 | // TODO: add support to ORACLE & SQLSERVER 14 | // EngineTypeOracle is the engine type for Oracle. 15 | // EngineTypeOracle = "ORACLE" 16 | // EngineTypeSQLServer is the engine type for SQLServer. 17 | // EngineTypeSQLServer = "SQLSERVER" 18 | ) 19 | 20 | // OfferInstancePayload is the payload of the offer type instance. 21 | type OfferInstancePayload struct { 22 | // e.g. db.lg, N1Standard-1-1 23 | Type string `json:"instanceType"` 24 | // e.g. HighMem, Generals 25 | InstanceFamily string `json:"instanceFamily"` 26 | CPU string `json:"cpu"` 27 | Memory string `json:"memory"` 28 | // e.g. Intel Lake 29 | PhysicalProcessor string `json:"physicalProcessor"` 30 | NetworkPerformance string `json:"networkPerformance"` 31 | DeploymentOption string `json:"deploymentOption"` 32 | DatabaseEngine EngineType `json:"databaseEngine"` 33 | } 34 | 35 | // ChargeType is the charge type of the price. 36 | type ChargeType string 37 | 38 | const ( 39 | // ChargeTypeOnDemand is the on demand type of the price. 40 | ChargeTypeOnDemand ChargeType = "OnDemand" 41 | // ChargeTypeReserved is the reserved type of the price. 42 | ChargeTypeReserved ChargeType = "Reserved" 43 | ) 44 | 45 | // OfferType is the type of the smallest offer type of a offer. 46 | // Some vendors may provide offer at a CPU/RAM level while others may only provide a specified instance. 47 | // Allowed OfferType are : Instance, RAM, CPU 48 | type OfferType string 49 | 50 | const ( 51 | // OfferTypeInstance is the offer type that provides specified instance as a basic unit. 52 | OfferTypeInstance OfferType = "Instance" 53 | // OfferTypeRAM is the offer type that provides RAM as a basic unit. 54 | OfferTypeRAM OfferType = "RAM" 55 | // OfferTypeCPU is the offer type that provides CPU as a basic unit. 56 | OfferTypeCPU OfferType = "CPU" 57 | ) 58 | 59 | // Currency is the type of the currency. 60 | type Currency string 61 | 62 | // CurrencyUSD is the type of the currency of USC. 63 | const CurrencyUSD = "USD" 64 | 65 | // ChargePayload is the charge payload of the offer. 66 | type ChargePayload struct { 67 | LeaseContractLength string `json:"leaseContractLength"` 68 | PurchaseOption string `json:"purchaseOption"` 69 | } 70 | 71 | // Offer is the api message of an Offer. 72 | type Offer struct { 73 | ID int 74 | 75 | // e.g. AWS: 9QH3PUGXCYKNCYPB, GCP: 0009-6F35-3126 76 | SKU string 77 | // The same SKU may have different charge type, the term code is used to differentiate this. 78 | // e.g. Instance A may be charged monthly(with term code 'a', SKU 'A') of daily((with term code 'b', SKU 'A')).\ 79 | // AWS: 9QH3PUGXCYKNCYPB.HU7G6KETJZ 80 | TermCode string 81 | // Allowed OfferType are Instance, RAM, CPU 82 | OfferType OfferType 83 | // If the offer type is Instance, the payload would be the information of that instance, otherwise this field will be nil 84 | InstancePayload *OfferInstancePayload 85 | 86 | // Possible ChargeType are reserved, onDemand 87 | ChargeType ChargeType 88 | // Payload is present when the ChargeType is Reserved, otherwise nil. 89 | ChargePayload *ChargePayload 90 | 91 | // RegionList is the region that share the same price of this offer 92 | RegionList []string 93 | Description string 94 | HourlyUSD float64 95 | // CommitmentUSD is the price need be paid in advance in order to get a discount in the hourly fee. 96 | // If commitment is not applicable, CommitmentUSD would be 0. 97 | CommitmentUSD float64 98 | } 99 | 100 | // Client is the client for http request. 101 | type Client interface { 102 | GetOffer() ([]*Offer, error) 103 | } 104 | -------------------------------------------------------------------------------- /client/gcp/gcp.go: -------------------------------------------------------------------------------- 1 | package gcp 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "regexp" 9 | "strconv" 10 | "strings" 11 | 12 | "github.com/bytebase/dbcost/client" 13 | ) 14 | 15 | // Client is the client struct 16 | type Client struct { 17 | apiKey string 18 | } 19 | 20 | var _ client.Client = (*Client)(nil) 21 | 22 | // NewClient return a client 23 | func NewClient(apiKey string) *Client { 24 | return &Client{apiKey} 25 | } 26 | 27 | const rdsServiceID = "9662-B51E-5089" 28 | 29 | // priceInfoEndpoint is the endpoint for all Cloud SQL services on GCP 30 | // For more information, please refer to https://cloud.google.com/billing/v1/how-tos/catalog-api 31 | var priceInfoEndpoint = fmt.Sprintf("https://cloudbilling.googleapis.com/v1/services/%s/skus", rdsServiceID) 32 | 33 | type unitPrice struct { 34 | CurrencyCode string `json:"currencyCode"` 35 | // The nanos is the number of nano (10^-9) units of the amount. The value must be between -999,999,999 and +999,999,999 inclusive. 36 | // The cost of the SKU is units + nanos. 37 | // For example, a cost of $1.75 is represented as units=1 and nanos=750,000,000. 38 | Unit string `json:"units"` 39 | Nano float64 `json:"nanos"` 40 | } 41 | 42 | type tieredRate struct { 43 | UnitPrice unitPrice `json:"unitPrice"` 44 | } 45 | 46 | type pricingExpression struct { 47 | UsageUnit string `json:"usageUnit"` 48 | TieredRateList []*tieredRate `json:"tieredRates"` 49 | } 50 | 51 | type pricingInfo struct { 52 | PricingExpression pricingExpression `json:"pricingExpression"` 53 | } 54 | 55 | type category struct { 56 | ServiceDisplayName string `json:"serviceDisplayName"` 57 | ResourceFamily string `json:"resourceFamily"` 58 | ResourceGroup string `json:"resourceGroup"` 59 | UsageType string `json:"usageType"` 60 | } 61 | 62 | type offer struct { 63 | ID string `json:"skuId"` 64 | Description string `json:"description"` 65 | Category category `json:"category"` 66 | PricingInfo []pricingInfo `json:"pricingInfo"` 67 | ServiceRegionList []string `json:"serviceRegions"` 68 | } 69 | 70 | type pricing struct { 71 | OfferList []*offer `json:"skus"` 72 | NextPageToken string `json:"nextPageToken"` 73 | } 74 | 75 | func (c *Client) getPricingWithPageToken(nextPageToken string) (*pricing, error) { 76 | endpoint := fmt.Sprintf("%s?key=%s", priceInfoEndpoint, c.apiKey) 77 | if nextPageToken != "" { 78 | endpoint = fmt.Sprintf("%s&pageToken=%s", endpoint, nextPageToken) 79 | } 80 | 81 | req, err := http.NewRequest("GET", endpoint, nil) 82 | res, err := http.DefaultClient.Do(req) 83 | if err != nil { 84 | return nil, fmt.Errorf("Fail to fetch the info file. Error: %v", err) 85 | } 86 | if res.StatusCode != 200 { 87 | return nil, fmt.Errorf("An http error occur. Error: %v", err) 88 | } 89 | 90 | data, err := io.ReadAll(res.Body) 91 | if err != nil { 92 | return nil, fmt.Errorf("Fail when reading response data. Error: %v", err) 93 | } 94 | 95 | p := &pricing{} 96 | if err := json.Unmarshal(data, p); err != nil { 97 | return nil, fmt.Errorf("Fail when unmarshaling response data. Error: %v", err) 98 | } 99 | return p, nil 100 | } 101 | 102 | // GetOffer return the offers provide by GCP. 103 | func (c *Client) GetOffer() ([]*client.Offer, error) { 104 | var rawOffer []*offer 105 | var token string 106 | for { 107 | p, err := c.getPricingWithPageToken(token) 108 | if err != nil { 109 | return nil, err 110 | } 111 | rawOffer = append(rawOffer, p.OfferList...) 112 | if p.NextPageToken == "" { 113 | break 114 | } 115 | token = p.NextPageToken 116 | } 117 | 118 | var offerList []*client.Offer 119 | incrID := 0 120 | for _, rawOffer := range rawOffer { 121 | // This condition will filter resource like Network。 122 | // For now, we only focus on the RDS Instance information. 123 | if rawOffer.Category.ResourceFamily != "ApplicationServices" || 124 | // the Gen1 offering is unavailabe 125 | strings.Contains(rawOffer.Category.ResourceGroup, "Gen1") || 126 | !rSpecification.Match([]byte(rawOffer.Description)) { 127 | continue 128 | } 129 | 130 | offerType := getOfferType(rawOffer.Category.ResourceGroup) 131 | hourlyUSD, err := getUSD(rawOffer.PricingInfo) 132 | if err != nil { 133 | return nil, err 134 | } 135 | 136 | offer := &client.Offer{ 137 | ID: incrID, 138 | SKU: rawOffer.ID, 139 | // TermCode in GCP is the same as its SKU as different term is identified as different SKU. 140 | TermCode: rawOffer.ID, 141 | OfferType: offerType, 142 | 143 | // All GCP services are charged on demand literal, but we consider commitment for a length as reserved type 144 | ChargeType: client.ChargeTypeOnDemand, 145 | 146 | RegionList: rawOffer.ServiceRegionList, 147 | Description: rawOffer.Description, 148 | 149 | CommitmentUSD: 0, 150 | HourlyUSD: hourlyUSD, 151 | } 152 | 153 | if offerType == client.OfferTypeInstance { 154 | databaseEngine, CPU, memory, err := getCPUMemory(rawOffer.Description) 155 | if err != nil { 156 | continue 157 | } 158 | 159 | instanceType := getInstanceType(rawOffer.Category.ResourceGroup, CPU, memory) 160 | payload := &client.OfferInstancePayload{ 161 | Type: instanceType, 162 | InstanceFamily: instanceType, 163 | CPU: CPU, 164 | Memory: memory, 165 | DatabaseEngine: databaseEngine, 166 | } 167 | offer.InstancePayload = payload 168 | } 169 | 170 | // TODO(zilong): implement it later. 171 | if offer.ChargeType == client.ChargeTypeReserved { 172 | continue 173 | } 174 | offerList = append(offerList, offer) 175 | incrID++ 176 | 177 | // This is a little bit hack. 178 | // GCP charge MySQL and PostgreSQL equally, but only provide MySQL at their API message. 179 | // We manually create a PostgreSQL with exactly the same price here. 180 | virtualSKU := fmt.Sprintf("%s-%s", rawOffer.ID, "PG") 181 | postgreSQLoffer := &client.Offer{ 182 | ID: incrID, 183 | SKU: virtualSKU, 184 | TermCode: virtualSKU, 185 | OfferType: offer.OfferType, 186 | ChargeType: offer.ChargeType, 187 | RegionList: offer.RegionList, 188 | Description: offer.Description, 189 | CommitmentUSD: offer.CommitmentUSD, 190 | HourlyUSD: offer.HourlyUSD, 191 | InstancePayload: &client.OfferInstancePayload{ 192 | Type: offer.InstancePayload.Type, 193 | InstanceFamily: offer.InstancePayload.InstanceFamily, 194 | CPU: offer.InstancePayload.CPU, 195 | Memory: offer.InstancePayload.Memory, 196 | DatabaseEngine: client.EngineTypePostgreSQL, 197 | }, 198 | } 199 | offerList = append(offerList, postgreSQLoffer) 200 | 201 | incrID++ 202 | } 203 | return offerList, nil 204 | } 205 | 206 | // getOfferType will extract the OfferType from a given resource group. 207 | // Possible resource group are: 208 | // SQLGen2Instance${INSTANE_CODE}, SQLGen2InstanceRAM, SQLGen2InstanceCPU. 209 | func getOfferType(resourceGroup string) client.OfferType { 210 | offerType := client.OfferType(strings.ReplaceAll(resourceGroup, "SQLGen2Instances", "")) 211 | if offerType != "RAM" && offerType != "CPU" { 212 | offerType = client.OfferTypeInstance 213 | } 214 | return offerType 215 | } 216 | 217 | // Regional Instance means High Available Instance, see https://cloud.google.com/sql/docs/postgres/high-availability 218 | var rSpecification = regexp.MustCompile(`Cloud SQL for ([\S|\s]+): Zonal - (\d+) vCPU \+ (\d+.\d*)GB RAM`) 219 | 220 | // getCPUMemory will use a reg-expression to extract the specification expressed in the given description 221 | // the description should follow the form of "Cloud SQL for ${ENGINE_TYPE}: Zonal - ${NUM_VCPU} vCPU + ${NUM_MEM}GB RAM". 222 | func getCPUMemory(description string) (databaseEngine client.EngineType, CPU string, memory string, err error) { 223 | match := rSpecification.FindStringSubmatch(description) 224 | if match[1] == "MySQL" { 225 | databaseEngine = client.EngineTypeMySQL 226 | } else if match[1] == "PostgreSQL" { 227 | databaseEngine = client.EngineTypePostgreSQL 228 | } 229 | 230 | return databaseEngine, match[2], match[3], nil 231 | } 232 | 233 | // getUSD will return a single value of the price in USD 234 | // pricing in GCP is seperated into to part, the unit and the nanos. 235 | // The nanos is the number of nano (10^-9) units of the amount. The value must be between -999,999,999 and +999,999,999 inclusive. 236 | // The cost of the SKU is units + nanos. 237 | // For example, a cost of $1.75 is represented as units=1 and nanos=750,000,000. 238 | func getUSD(pricingInfo []pricingInfo) (float64, error) { 239 | if len(pricingInfo) == 0 || len(pricingInfo[0].PricingExpression.TieredRateList) == 0 { 240 | return 0, fmt.Errorf("Incomplete type") 241 | } 242 | unitPrice := pricingInfo[0].PricingExpression.TieredRateList[0].UnitPrice 243 | unitInt64, err := strconv.Atoi(unitPrice.Unit) 244 | if err != nil { 245 | return 0, err 246 | } 247 | return float64(unitInt64) + unitPrice.Nano/1e9, nil 248 | } 249 | 250 | // getInstanceType will return a the type of the given instance 251 | func getInstanceType(resourceGroup, CPU, memory string) string { 252 | serviceCode := strings.ReplaceAll(resourceGroup, "SQLGen2Instances", "") 253 | return fmt.Sprintf("db-%v-%v-%v", serviceCode, CPU, memory) 254 | } 255 | -------------------------------------------------------------------------------- /client/gcp/gcp_test.go: -------------------------------------------------------------------------------- 1 | package gcp 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func Test_GetOffer(t *testing.T) { 10 | c := NewClient("demo api key") 11 | _, err := c.GetOffer() 12 | require.NoError(t, err) 13 | } 14 | -------------------------------------------------------------------------------- /data/sample.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 0, 4 | "externalId": "AAA", 5 | "rowStatus": "NORMAL", 6 | "creatorId": 0, 7 | "updaterId": 0, 8 | "regionList": [ 9 | { 10 | "code": "us-east-1", 11 | "termList": [ 12 | { 13 | "code": "AAA.a", 14 | "databaseEngine": "POSTGRES", 15 | "type": "OnDemand", 16 | "payload": null, 17 | "hourlyUSD": 2.158, 18 | "commitmentUSD": 0 19 | }, 20 | { 21 | "code": "AAA.b", 22 | "databaseEngine": "MYSQL", 23 | "type": "OnDemand", 24 | "payload": null, 25 | "hourlyUSD": 2.041, 26 | "commitmentUSD": 0 27 | }, 28 | { 29 | "code": "AAA.c", 30 | "databaseEngine": "POSTGRES", 31 | "type": "Reserved", 32 | "payload": { 33 | "leaseContractLength": "3yr", 34 | "purchaseOption": "Partial Upfront" 35 | }, 36 | "hourlyUSD": 0.4856, 37 | "commitmentUSD": 12761 38 | }, 39 | { 40 | "code": "AAA.d", 41 | "databaseEngine": "POSTGRES", 42 | "type": "Reserved", 43 | "payload": { 44 | "leaseContractLength": "1yr", 45 | "purchaseOption": "No Upfront" 46 | }, 47 | "hourlyUSD": 1.4732, 48 | "commitmentUSD": 0 49 | }, 50 | { 51 | "code": "AAA.e", 52 | "databaseEngine": "POSTGRES", 53 | "type": "Reserved", 54 | "payload": { 55 | "leaseContractLength": "1yr", 56 | "purchaseOption": "All Upfront" 57 | }, 58 | "hourlyUSD": 0, 59 | "commitmentUSD": 12048 60 | }, 61 | { 62 | "code": "AAA.f", 63 | "databaseEngine": "POSTGRES", 64 | "type": "Reserved", 65 | "payload": { 66 | "leaseContractLength": "1yr", 67 | "purchaseOption": "Partial Upfront" 68 | }, 69 | "hourlyUSD": 0.7013, 70 | "commitmentUSD": 6144 71 | }, 72 | { 73 | "code": "AAA.g", 74 | "databaseEngine": "POSTGRES", 75 | "type": "Reserved", 76 | "payload": { 77 | "leaseContractLength": "3yr", 78 | "purchaseOption": "All Upfront" 79 | }, 80 | "hourlyUSD": 0, 81 | "commitmentUSD": 25012 82 | }, 83 | { 84 | "code": "AAA.h", 85 | "databaseEngine": "MYSQL", 86 | "type": "Reserved", 87 | "payload": { 88 | "leaseContractLength": "3yr", 89 | "purchaseOption": "Partial Upfront" 90 | }, 91 | "hourlyUSD": 0.459, 92 | "commitmentUSD": 12063 93 | }, 94 | { 95 | "code": "AAA.k", 96 | "databaseEngine": "MYSQL", 97 | "type": "Reserved", 98 | "payload": { 99 | "leaseContractLength": "1yr", 100 | "purchaseOption": "No Upfront" 101 | }, 102 | "hourlyUSD": 1.3926, 103 | "commitmentUSD": 0 104 | }, 105 | { 106 | "code": "AAA.l", 107 | "databaseEngine": "MYSQL", 108 | "type": "Reserved", 109 | "payload": { 110 | "leaseContractLength": "1yr", 111 | "purchaseOption": "All Upfront" 112 | }, 113 | "hourlyUSD": 0, 114 | "commitmentUSD": 11384 115 | }, 116 | { 117 | "code": "AAA.m", 118 | 119 | "databaseEngine": "MYSQL", 120 | "type": "Reserved", 121 | "payload": { 122 | "leaseContractLength": "1yr", 123 | "purchaseOption": "Partial Upfront" 124 | }, 125 | "hourlyUSD": 0.6629, 126 | "commitmentUSD": 5807 127 | }, 128 | { 129 | "code": "AAA.n", 130 | "databaseEngine": "MYSQL", 131 | "type": "Reserved", 132 | "payload": { 133 | "leaseContractLength": "3yr", 134 | "purchaseOption": "All Upfront" 135 | }, 136 | "hourlyUSD": 0, 137 | "commitmentUSD": 23649 138 | } 139 | ] 140 | }, 141 | { 142 | "code": "ap-south-1", 143 | "termList": [ 144 | { 145 | "code": "BBB.a", 146 | "databaseEngine": "MYSQL", 147 | "type": "OnDemand", 148 | "payload": null, 149 | "hourlyUSD": 1.933, 150 | "commitmentUSD": 0 151 | }, 152 | { 153 | "code": "BBB.b", 154 | "databaseEngine": "POSTGRES", 155 | "type": "OnDemand", 156 | "payload": null, 157 | "hourlyUSD": 2.05, 158 | "commitmentUSD": 0 159 | }, 160 | { 161 | "code": "BBB.c", 162 | "databaseEngine": "MYSQL", 163 | "type": "Reserved", 164 | "payload": { 165 | "leaseContractLength": "1yr", 166 | "purchaseOption": "All Upfront" 167 | }, 168 | "hourlyUSD": 0, 169 | "commitmentUSD": 10790 170 | }, 171 | { 172 | "code": "BBB.d", 173 | "databaseEngine": "MYSQL", 174 | "type": "Reserved", 175 | "payload": { 176 | "leaseContractLength": "1yr", 177 | "purchaseOption": "Partial Upfront" 178 | }, 179 | "hourlyUSD": 0.6293, 180 | "commitmentUSD": 5513 181 | }, 182 | { 183 | "code": "BBB.e", 184 | "databaseEngine": "MYSQL", 185 | "type": "Reserved", 186 | "payload": { 187 | "leaseContractLength": "3yr", 188 | "purchaseOption": "All Upfront" 189 | }, 190 | "hourlyUSD": 0, 191 | "commitmentUSD": 21910 192 | }, 193 | { 194 | "code": "BBB.f", 195 | "databaseEngine": "MYSQL", 196 | "type": "Reserved", 197 | "payload": { 198 | "leaseContractLength": "3yr", 199 | "purchaseOption": "Partial Upfront" 200 | }, 201 | "hourlyUSD": 0.4239, 202 | "commitmentUSD": 11139 203 | }, 204 | { 205 | "code": "BBB.g", 206 | "databaseEngine": "MYSQL", 207 | "type": "Reserved", 208 | "payload": { 209 | "leaseContractLength": "1yr", 210 | "purchaseOption": "No Upfront" 211 | }, 212 | "hourlyUSD": 1.3174, 213 | "commitmentUSD": 0 214 | }, 215 | { 216 | "code": "BBB.h", 217 | "databaseEngine": "POSTGRES", 218 | "type": "Reserved", 219 | "payload": { 220 | "leaseContractLength": "3yr", 221 | "purchaseOption": "Partial Upfront" 222 | }, 223 | "hourlyUSD": 0.4511, 224 | "commitmentUSD": 11854 225 | }, 226 | { 227 | "code": "BBB.j", 228 | "databaseEngine": "POSTGRES", 229 | "type": "Reserved", 230 | "payload": { 231 | "leaseContractLength": "1yr", 232 | "purchaseOption": "No Upfront" 233 | }, 234 | "hourlyUSD": 1.3992, 235 | "commitmentUSD": 0 236 | }, 237 | { 238 | "code": "BBB.l", 239 | "databaseEngine": "POSTGRES", 240 | "type": "Reserved", 241 | "payload": { 242 | "leaseContractLength": "1yr", 243 | "purchaseOption": "All Upfront" 244 | }, 245 | "hourlyUSD": 0, 246 | "commitmentUSD": 11442 247 | }, 248 | { 249 | "code": "BBB.m", 250 | "databaseEngine": "POSTGRES", 251 | "type": "Reserved", 252 | "payload": { 253 | "leaseContractLength": "1yr", 254 | "purchaseOption": "Partial Upfront" 255 | }, 256 | "hourlyUSD": 0.6661, 257 | "commitmentUSD": 5835 258 | }, 259 | { 260 | "code": "BBB.n", 261 | "databaseEngine": "POSTGRES", 262 | "type": "Reserved", 263 | "payload": { 264 | "leaseContractLength": "3yr", 265 | "purchaseOption": "All Upfront" 266 | }, 267 | "hourlyUSD": 0, 268 | "commitmentUSD": 23235 269 | } 270 | ] 271 | } 272 | ], 273 | "cloudProvider": "AWS", 274 | "name": "db.r6g.4xlarge", 275 | "cpu": 16, 276 | "memory": "128", 277 | "processor": "AWS Graviton2" 278 | }, 279 | { 280 | "id": 1, 281 | "externalId": "000E-8560-3D8D", 282 | "rowStatus": "NORMAL", 283 | "creatorId": 0, 284 | "updaterId": 0, 285 | "regionList": [ 286 | { 287 | "code": "us-east4", 288 | "termList": [ 289 | { 290 | "code": "000E-8560-3D8D", 291 | "databaseEngine": "MYSQL", 292 | "type": "OnDemand", 293 | "payload": null, 294 | "hourlyUSD": 10.158, 295 | "commitmentUSD": 0 296 | } 297 | ] 298 | } 299 | ], 300 | "cloudProvider": "GCP", 301 | "name": "db-N1Standard-96-360", 302 | "cpu": 96, 303 | "memory": "360", 304 | "processor": "" 305 | } 306 | ] 307 | -------------------------------------------------------------------------------- /frontend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "prettier"], 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": ["@typescript-eslint"], 5 | "rules": { 6 | "@typescript-eslint/no-unused-vars": "warn", 7 | "no-console": "warn" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /frontend/.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers=true -------------------------------------------------------------------------------- /frontend/components/ButtonGroup.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import Link from "next/link"; 3 | import { Button } from "antd"; 4 | import { CheckIcon } from "@radix-ui/react-icons"; 5 | import { useSearchConfigContext } from "@/stores"; 6 | 7 | interface Props { 8 | type: "reset" | "back"; 9 | showCompare?: boolean; 10 | } 11 | 12 | const ButtonGroup: React.FC = ({ type }) => { 13 | const { reset: resetSearchConfig } = useSearchConfigContext(); 14 | const [isCopied, setIsCopied] = useState(false); 15 | 16 | const handleCopy = async () => { 17 | await navigator.clipboard.writeText(document.location.href); 18 | setIsCopied(true); 19 | }; 20 | 21 | return ( 22 |
23 | {type === "reset" ? ( 24 | 27 | ) : ( 28 | 29 | 30 | 31 | )} 32 | 41 |
42 | ); 43 | }; 44 | 45 | export default ButtonGroup; 46 | -------------------------------------------------------------------------------- /frontend/components/CompareBadgeGroup.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Divider } from "antd"; 3 | import { getDigit } from "@/utils"; 4 | 5 | enum BadgeTypes { 6 | TEXT = "TEXT", 7 | } 8 | 9 | interface BadgeProps { 10 | type: BadgeTypes; 11 | text: string | number | null; 12 | description: string; 13 | } 14 | 15 | export interface BadgeRow { 16 | instanceName: string; 17 | cpu: number; 18 | memory: number; 19 | processor: string | null; 20 | regionCount: number; 21 | hourly: string; 22 | } 23 | 24 | interface BadgeGroupProps { 25 | dataSource: BadgeRow[]; 26 | } 27 | 28 | const CompareBadge: React.FC = ({ type, text, description }) => { 29 | switch (type) { 30 | case BadgeTypes.TEXT: 31 | return ( 32 |
33 | 34 | {text ?? "-"} 35 | 36 |
{description}
37 |
38 | ); 39 | 40 | default: 41 | return null; 42 | } 43 | }; 44 | 45 | const CompareBadgeGroup: React.FC = ({ dataSource }) => { 46 | return ( 47 |
48 |
49 | {dataSource.map( 50 | ({ instanceName, cpu, memory, processor, regionCount, hourly }) => ( 51 | 52 |
53 | {instanceName} 54 |
55 | 56 | 61 | 66 | 71 | 76 | 81 |
82 | ) 83 | )} 84 |
85 |
86 | ); 87 | }; 88 | 89 | export default CompareBadgeGroup; 90 | -------------------------------------------------------------------------------- /frontend/components/CompareMenu.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { Select, Button } from "antd"; 3 | import { useRouter } from "next/router"; 4 | import { nameToSlug } from "utils"; 5 | import { DBInstance } from "@/types"; 6 | import data from "@data"; 7 | 8 | const instanceTypeList = (data as DBInstance[]).map((dbInstance, index) => ({ 9 | label: dbInstance.name, 10 | value: index, 11 | })); 12 | 13 | const CompareMenu: React.FC = () => { 14 | const [comparer, setComparer] = useState<{ 15 | first?: { value: string; index: number }; 16 | second?: { value: string; index: number }; 17 | }>({}); 18 | const [secondaryOptions, setSecondaryOptions] = useState< 19 | { label: string; value: number }[] 20 | >([]); 21 | const [isInstanceSelected, setIsInstanceSelected] = useState(true); 22 | const router = useRouter(); 23 | 24 | return ( 25 |
26 |
27 | { 55 | if (option?.label) { 56 | const { label } = option; 57 | return label.includes(inputValue); 58 | } 59 | return false; 60 | }} 61 | value={comparer.second?.value} 62 | onSelect={(index: any): void => { 63 | setComparer({ 64 | ...comparer, 65 | second: { value: instanceTypeList[index].label, index }, 66 | }); 67 | setIsInstanceSelected(true); 68 | }} 69 | options={secondaryOptions} 70 | /> 71 | 90 | {!isInstanceSelected && ( 91 |

92 | Please select instances. 93 |

94 | )} 95 |
96 |
97 | ); 98 | }; 99 | 100 | export default CompareMenu; 101 | -------------------------------------------------------------------------------- /frontend/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | const Footer: React.FC = () => { 2 | return ( 3 | 50 | ); 51 | }; 52 | 53 | export default Footer; 54 | -------------------------------------------------------------------------------- /frontend/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import Link from "next/link"; 3 | import { useRouter } from "next/router"; 4 | import Tooltip from "@/components/primitives/Tooltip"; 5 | import { useSearchConfigContext } from "@/stores"; 6 | 7 | const providerList = ["aws", "gcp"]; 8 | const engineList = ["mysql", "postgres"]; 9 | const providerPagePathname = "/provider/[provider]"; 10 | 11 | const Header: React.FC = () => { 12 | const { reset: resetSearchConfig } = useSearchConfigContext(); 13 | const router = useRouter(); 14 | const { provider: providerInRoute, engine: engineInRouter } = router.query; 15 | 16 | return ( 17 |
18 |
19 | {/* logo and provider entries */} 20 |
21 | 22 |
void resetSearchConfig()} 25 | > 26 | DB Cost 33 |
34 | 35 | 43 |
44 | 45 |
46 | 47 | 48 | AWS RDS 49 | 50 | 51 |
|
52 | 53 | 54 | GCP Cloud SQL 55 | 56 | 57 |
58 | 59 | {/* star and sponsor */} 60 |
61 |
62 | by 63 |
64 | 68 |
{ 71 | window.open("https://bytebase.com?ref=dbcost", "_blank"); 72 | }} 73 | > 74 | Bytebase 81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 | ); 89 | }; 90 | 91 | export default Header; 92 | -------------------------------------------------------------------------------- /frontend/components/Icon.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from "react"; 2 | import Image from "next/image"; 3 | import { getIconPath } from "@/utils"; 4 | 5 | interface Props { 6 | name: string; 7 | // https://nextjs.org/docs/api-reference/next/image#sizes 8 | sizes: string; 9 | } 10 | 11 | /* Pure Component */ 12 | const Icon: React.FC = ({ name, sizes }) => { 13 | return ( 14 | {name} 21 | ); 22 | }; 23 | 24 | export default memo(Icon); 25 | -------------------------------------------------------------------------------- /frontend/components/LineChart.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import { Empty } from "antd"; 3 | import { Line } from "@nivo/line"; 4 | import { useSearchConfigContext } from "@/stores"; 5 | import { getDigit, withComma, filterNaN, getPrice } from "@/utils"; 6 | import { 7 | DataSource, 8 | monthDays, 9 | commonProperties, 10 | EngineType, 11 | PageType, 12 | } from "@/types"; 13 | 14 | interface Props { 15 | type: PageType; 16 | dataSource: DataSource[]; 17 | } 18 | 19 | interface ChartData { 20 | id: string; 21 | data: { 22 | x: number; 23 | y: string; 24 | }[]; 25 | } 26 | 27 | const getHourCountByMonth = (month: number): number => { 28 | let res = 0; 29 | for (let i = 0; i < month; i++) { 30 | res += monthDays[i % 12]; 31 | } 32 | return res * 24; 33 | }; 34 | 35 | const generateChartData = ( 36 | type: PageType, 37 | xLength: number, 38 | dataSource: DataSource[], 39 | utilization: number, 40 | engineType: EngineType[] 41 | ) => { 42 | // Will generate [1, 2, 3, ..., xLength]. 43 | const xGrid = Array.from({ length: xLength }, (_, i) => i + 1); 44 | const res = []; 45 | 46 | for (const row of dataSource) { 47 | const fees = []; 48 | 49 | // Deal with on demand instances. 50 | if (row.leaseLength === "On Demand") { 51 | for (const x of xGrid) { 52 | const totalCost = getHourCountByMonth(x) * utilization * row.hourly.usd; 53 | fees.push({ 54 | x, 55 | y: getDigit(totalCost, 0), 56 | }); 57 | } 58 | switch (type) { 59 | case PageType.INSTANCE_DETAIL: 60 | res.push({ 61 | id: `${row.region}${ 62 | engineType.length > 1 ? ` - ${row.engineType}` : "" 63 | }${row.leaseLength === "On Demand" ? "" : "-" + row.leaseLength}`, 64 | data: fees, 65 | }); 66 | break; 67 | case PageType.REGION_DETAIL: 68 | res.push({ 69 | id: `${row.name}${ 70 | engineType.length > 1 ? ` - ${row.engineType}` : "" 71 | }${row.leaseLength === "On Demand" ? "" : "-" + row.leaseLength}`, 72 | data: fees, 73 | }); 74 | break; 75 | case PageType.INSTANCE_COMPARISON: 76 | res.push({ 77 | id: `${row.name}${ 78 | engineType.length > 1 ? ` - ${row.engineType}` : "" 79 | }-${row.leaseLength}`, 80 | data: fees, 81 | }); 82 | break; 83 | 84 | default: 85 | break; 86 | } 87 | } else { 88 | // Deal with reserved instances. 89 | // Reserved plans' line starts from (1, 0). 90 | fees.push({ 91 | x: 1, 92 | y: "0", 93 | }); 94 | switch (row.leaseLength) { 95 | case "1yr": 96 | for (let i = 1; i <= 3; i++) { 97 | const totalCost = getPrice(row, 1, i); 98 | fees.push({ 99 | x: i * 12, 100 | y: getDigit(totalCost, 0), 101 | }); 102 | } 103 | break; 104 | case "3yr": 105 | const totalCost = getPrice(row, 1, 3); 106 | fees.push({ 107 | x: 3 * 12, 108 | y: getDigit(totalCost, 0), 109 | }); 110 | break; 111 | 112 | default: 113 | break; 114 | } 115 | res.push({ 116 | id: `${row.name}${ 117 | engineType.length > 1 ? ` - ${row.engineType}` : "" 118 | }-${row.leaseLength}-commitment-$${row.commitment.usd}`, 119 | data: fees, 120 | }); 121 | } 122 | } 123 | return res; 124 | }; 125 | 126 | const getNearbyPoints = (data: ChartData[], x: number, serieId: string) => { 127 | // Display at most five different values. 128 | const range = 5; 129 | // For each different value, display at most two same values. 130 | const sameValueLimit = 2; 131 | const higherPoints = []; 132 | const lowerPoints = []; 133 | let hasMoreHigherPoints: boolean = false, 134 | hasMoreLowerPoints: boolean = false; 135 | 136 | const pointSlice = data 137 | .map(({ id, data }) => { 138 | const point = data.find((d) => d.x === x); 139 | return { 140 | id, 141 | x, 142 | y: Number(point?.y), 143 | }; 144 | }) 145 | .sort((a, b) => b.y - a.y); 146 | const currPointIndex = pointSlice.findIndex((d) => d.id === serieId); 147 | 148 | // At most five different y values higher than current point. 149 | for ( 150 | let i = currPointIndex - 1, r = range, l = sameValueLimit; 151 | i >= 0 && r > 0; 152 | i-- 153 | ) { 154 | if (pointSlice[i].y !== pointSlice[i + 1].y) { 155 | r--; 156 | if (r === 0) { 157 | hasMoreHigherPoints = true; 158 | } 159 | l = sameValueLimit - 1; 160 | higherPoints.unshift(pointSlice[i]); 161 | } else { 162 | if (l > 0) { 163 | higherPoints.unshift(pointSlice[i]); 164 | l--; 165 | } 166 | } 167 | } 168 | // At most five different y values lower than current point. 169 | for ( 170 | let i = currPointIndex + 1, r = range, l = sameValueLimit; 171 | i < pointSlice.length && r > 0; 172 | i++ 173 | ) { 174 | if (pointSlice[i].y !== pointSlice[i - 1].y) { 175 | r--; 176 | if (r === 0) { 177 | hasMoreLowerPoints = true; 178 | } 179 | l = sameValueLimit - 1; 180 | lowerPoints.push(pointSlice[i]); 181 | } else { 182 | if (l > 0) { 183 | lowerPoints.push(pointSlice[i]); 184 | l--; 185 | } 186 | } 187 | } 188 | 189 | return { 190 | higherPoints, 191 | lowerPoints, 192 | hasMoreHigherPoints, 193 | hasMoreLowerPoints, 194 | }; 195 | }; 196 | 197 | const LineChart: React.FC = ({ type, dataSource }) => { 198 | const [data, setData] = useState([]); 199 | const { searchConfig } = useSearchConfigContext(); 200 | 201 | useEffect(() => { 202 | let length = 12; 203 | if (searchConfig.leaseLength > 1) { 204 | length *= searchConfig.leaseLength; 205 | } 206 | // In comparison pages, we compare between 3-year-plans. 207 | if (type === PageType.INSTANCE_COMPARISON) { 208 | length = 36; 209 | } 210 | const res = generateChartData( 211 | type, 212 | length, 213 | dataSource, 214 | searchConfig.utilization, 215 | searchConfig.engineType 216 | ); 217 | setData(res); 218 | }, [ 219 | dataSource, 220 | searchConfig.engineType.length, 221 | searchConfig.utilization, 222 | searchConfig.leaseLength, 223 | searchConfig.engineType, 224 | type, 225 | ]); 226 | 227 | // https://github.com/plouc/nivo/issues/1006#issuecomment-797091909 228 | useEffect(() => { 229 | setData(data); 230 | }, [data]); 231 | 232 | return data.length > 0 ? ( 233 | `${value} $`} 243 | axisLeft={{ 244 | legend: "Cost (USD)", 245 | legendOffset: -76, 246 | legendPosition: "middle", 247 | format: withComma, 248 | }} 249 | axisBottom={{ 250 | legend: "Number of Months", 251 | legendOffset: 40, 252 | legendPosition: "middle", 253 | }} 254 | useMesh={true} 255 | enableSlices={false} 256 | tooltip={({ point }) => { 257 | const { 258 | higherPoints, 259 | lowerPoints, 260 | hasMoreHigherPoints, 261 | hasMoreLowerPoints, 262 | } = getNearbyPoints(data, Number(point.data.x), String(point.serieId)); 263 | return ( 264 |
270 |
271 | Total cost of the first {point.data.xFormatted} month(s). 272 |
273 | {hasMoreHigherPoints &&
...
} 274 | {higherPoints.map((point) => ( 275 |
276 |
277 | {point.id} 278 |
279 | {filterNaN(withComma(point.y))} $ 280 |
281 | ))} 282 |
286 |
287 |
293 | {point.serieId} 294 |
295 | 296 | {filterNaN(withComma(point.data.yFormatted))} 297 | 298 |
299 | {lowerPoints.map((point) => ( 300 |
301 |
302 | {point.id} 303 |
304 | {filterNaN(withComma(point.y))} $ 305 |
306 | ))} 307 | {hasMoreLowerPoints &&
...
} 308 |
309 | ); 310 | }} 311 | /> 312 | ) : ( 313 |
314 | 315 |
316 | ); 317 | }; 318 | 319 | export default LineChart; 320 | -------------------------------------------------------------------------------- /frontend/components/RegionMenu.tsx: -------------------------------------------------------------------------------- 1 | import { useReducer, useState, useEffect, useMemo } from "react"; 2 | import { useRouter } from "next/router"; 3 | import { Checkbox } from "antd"; 4 | import type { CheckboxValueType } from "antd/es/checkbox/Group"; 5 | import { CheckboxChangeEvent } from "antd/lib/checkbox"; 6 | import Icon from "@/components/Icon"; 7 | import { useSearchConfigContext } from "@/stores"; 8 | import { AvailableRegion, SearchConfigDefault } from "@/types"; 9 | 10 | interface Props { 11 | availableRegionList: AvailableRegion[]; 12 | } 13 | 14 | interface LocalState { 15 | regionMap: Record; 16 | parentRegionList: string[]; 17 | checkedParentRegionList: string[]; 18 | } 19 | 20 | const initialState: LocalState = { 21 | regionMap: {}, 22 | parentRegionList: SearchConfigDefault.region.map(getParentRegionName), 23 | checkedParentRegionList: [], 24 | }; 25 | 26 | enum ReducerActionsType { 27 | CLEAR_MAP = "CLEAR_MAP", 28 | UPDATE_REGION_MAP = "UPDATE_REGION_MAP", 29 | SORT_PARENT_REGION_LIST = "SORT_PARENT_REGION_LIST", 30 | FILTER_CHECKED_PARENT_REGION_LIST = "FILTER_CHECKED_PARENT_REGION_LIST", 31 | } 32 | 33 | interface ReducerActions { 34 | type: ReducerActionsType; 35 | payload: any; 36 | } 37 | 38 | // "Asia Pacific (Hong Kong)" ---> ["Asia Pacific (Hong Kong", ""] ---> ["Asia Pacific", "Hong Kong"] 39 | function getParentRegionName(regionName: string) { 40 | return regionName.split(")")[0].split(" (")[0]; 41 | } 42 | 43 | const reducer = (state: LocalState, action: ReducerActions): LocalState => { 44 | const { type, payload } = action; 45 | switch (type) { 46 | case ReducerActionsType.CLEAR_MAP: 47 | return { 48 | ...state, 49 | regionMap: {}, 50 | parentRegionList: [], 51 | }; 52 | case ReducerActionsType.UPDATE_REGION_MAP: 53 | const { parent, region } = payload; 54 | if (state.regionMap[parent]) { 55 | return { 56 | ...state, 57 | regionMap: { 58 | ...state.regionMap, 59 | [parent]: [...state.regionMap[parent], region.name], 60 | }, 61 | }; 62 | } else { 63 | return { 64 | ...state, 65 | regionMap: { 66 | ...state.regionMap, 67 | [parent]: [region.name], 68 | }, 69 | parentRegionList: Array.from( 70 | new Set([...state.parentRegionList, parent]) 71 | ), 72 | }; 73 | } 74 | case ReducerActionsType.SORT_PARENT_REGION_LIST: 75 | return { 76 | ...state, 77 | parentRegionList: state.parentRegionList.sort((a, b) => { 78 | if (a.includes("Other")) { 79 | return 1; 80 | } else if (b.includes("Other")) { 81 | return -1; 82 | } 83 | return a.localeCompare(b); 84 | }), 85 | }; 86 | case ReducerActionsType.FILTER_CHECKED_PARENT_REGION_LIST: 87 | const { set } = payload; 88 | return { 89 | ...state, 90 | checkedParentRegionList: state.parentRegionList.filter( 91 | (parentRegionName) => set.has(parentRegionName) 92 | ), 93 | }; 94 | default: 95 | return state; 96 | } 97 | }; 98 | 99 | const RegionMenu: React.FC = ({ availableRegionList }) => { 100 | const { searchConfig, update } = useSearchConfigContext(); 101 | const checkedRegionList = searchConfig.region; 102 | const setRegion = (regionList: string[]) => update("region", regionList); 103 | const router = useRouter(); 104 | const { provider } = router.query; 105 | 106 | const [routeParamProvider, setRouteParamProvider] = useState>( 107 | new Set(["GCP", "AWS"]) 108 | ); 109 | const [state, dispatch] = useReducer(reducer, initialState); 110 | 111 | const activeAvailableRegionList = useMemo(() => { 112 | // all provider is included (AWS, GCP) 113 | if (routeParamProvider.size === 2) { 114 | return availableRegionList; 115 | } 116 | if (routeParamProvider.has("AWS")) { 117 | return availableRegionList.filter((region) => 118 | region.providerCode.has("AWS") 119 | ); 120 | } 121 | if (routeParamProvider.has("GCP")) { 122 | return availableRegionList.filter((region) => 123 | region.providerCode.has("GCP") 124 | ); 125 | } 126 | return []; 127 | }, [availableRegionList, routeParamProvider]); 128 | 129 | const isIndeterminate = useMemo( 130 | () => 131 | (parentName: string): boolean => { 132 | if (parentName === "all") { 133 | return ( 134 | checkedRegionList.length > 0 && 135 | checkedRegionList.length < activeAvailableRegionList.length 136 | ); 137 | } 138 | 139 | const childRegionList = state.regionMap[parentName]; 140 | const childSRegionSet = new Set(childRegionList); 141 | let count = 0; 142 | for (const region of checkedRegionList) { 143 | if (childSRegionSet.has(region)) { 144 | count++; 145 | } 146 | } 147 | 148 | return 0 < count && count < childRegionList.length; 149 | }, 150 | [activeAvailableRegionList.length, checkedRegionList, state.regionMap] 151 | ); 152 | 153 | const getChildRegionNameList = ( 154 | parentNameList: string[], 155 | filterOutIndeterminate: boolean = false 156 | ): string[] => { 157 | if (filterOutIndeterminate) { 158 | return activeAvailableRegionList 159 | .filter( 160 | (region) => 161 | parentNameList.includes(getParentRegionName(region.name)) && 162 | !isIndeterminate(getParentRegionName(region.name)) 163 | ) 164 | .map((region) => region.name); 165 | } else { 166 | return activeAvailableRegionList 167 | .filter((region) => 168 | parentNameList.includes(getParentRegionName(region.name)) 169 | ) 170 | .map((region) => region.name); 171 | } 172 | }; 173 | 174 | const handleSelectAll = (e: CheckboxChangeEvent) => { 175 | const { checked } = e.target; 176 | if (checked) { 177 | // Select all regions. 178 | const newRegionList = getChildRegionNameList(state.parentRegionList); 179 | setRegion(newRegionList); 180 | } else { 181 | // Unselect all regions. 182 | setRegion([]); 183 | } 184 | }; 185 | 186 | const handleParentRegionChange = (checkedList: CheckboxValueType[]) => { 187 | const includesIndeterminate = checkedList.some((parentName) => 188 | isIndeterminate(parentName as string) 189 | ); 190 | // Do different things depending on whether checkedList includes indeterminate parent regions. 191 | if (includesIndeterminate) { 192 | // First, filter out indeterminate parent regions by passing true to the second argument. 193 | const newRegionList = getChildRegionNameList( 194 | checkedList as string[], 195 | true 196 | ); 197 | for (const parent of checkedList as string[]) { 198 | if (isIndeterminate(parent)) { 199 | // Then, add child regions of indeterminate parent regions back, 200 | // so that we keep the indeterminate state untouched. 201 | newRegionList.push( 202 | ...activeAvailableRegionList 203 | .filter((region) => state.regionMap[parent].includes(region.name)) 204 | .map((region) => region.name) 205 | .filter((regionName) => checkedRegionList.includes(regionName)) 206 | ); 207 | } 208 | } 209 | setRegion(newRegionList); 210 | } else { 211 | const newRegionList = getChildRegionNameList(checkedList as string[]); 212 | setRegion(newRegionList); 213 | } 214 | }; 215 | 216 | const handleChildRegionChange = ( 217 | checkedChildRegionList: CheckboxValueType[] 218 | ) => void setRegion(checkedChildRegionList as string[]); 219 | 220 | useEffect(() => { 221 | if (typeof provider === "string") { 222 | setRouteParamProvider(new Set([provider.toUpperCase()])); 223 | } 224 | }, [provider]); 225 | 226 | // Initialize regionMap and parentRegionList. 227 | useEffect(() => { 228 | if (activeAvailableRegionList.length > 0) { 229 | dispatch({ 230 | type: ReducerActionsType.CLEAR_MAP, 231 | payload: null, 232 | }); 233 | 234 | for (const region of activeAvailableRegionList) { 235 | const parent = getParentRegionName(region.name); 236 | dispatch({ 237 | type: ReducerActionsType.UPDATE_REGION_MAP, 238 | payload: { parent, region }, 239 | }); 240 | } 241 | dispatch({ 242 | type: ReducerActionsType.SORT_PARENT_REGION_LIST, 243 | payload: null, 244 | }); 245 | } 246 | }, [activeAvailableRegionList]); 247 | 248 | // Uncheck the parent region if all its child regions are unchecked. 249 | useEffect(() => { 250 | const set = new Set(); 251 | for (const regionName of checkedRegionList) { 252 | set.add(getParentRegionName(regionName)); 253 | } 254 | dispatch({ 255 | type: ReducerActionsType.FILTER_CHECKED_PARENT_REGION_LIST, 256 | payload: { set }, 257 | }); 258 | }, [checkedRegionList, state.parentRegionList]); 259 | 260 | return ( 261 |
262 | {/* parent region */} 263 |
264 | 272 | Select All 273 | 274 | 279 | {state.parentRegionList.map((parentRegion) => ( 280 | 286 | {parentRegion} 287 | 288 | ))} 289 | 290 |
291 | 292 | {/* child region */} 293 |
294 | 299 | {activeAvailableRegionList.map((region) => ( 300 |
301 | {region.name} 302 | {region.providerCode.has("AWS") && ( 303 |
304 | 305 |
306 | )} 307 | {region.providerCode.has("GCP") && ( 308 |
309 | 310 |
311 | )} 312 |
313 | ))} 314 |
315 |
316 |
317 | ); 318 | }; 319 | 320 | export default RegionMenu; 321 | -------------------------------------------------------------------------------- /frontend/components/RegionPricingTable.tsx: -------------------------------------------------------------------------------- 1 | import { Table } from "antd"; 2 | import Link from "next/link"; 3 | import type { ColumnsType } from "antd/es/table"; 4 | import { QuestionMarkCircledIcon } from "@radix-ui/react-icons"; 5 | import slug from "slug"; 6 | import Tooltip from "@/components/primitives/Tooltip"; 7 | import TdCell from "@/components/TdCell"; 8 | import { useSearchConfigContext } from "@/stores"; 9 | import { getDigit, YearInHour, withComma } from "@/utils"; 10 | import { RegionPricingType } from "@/types"; 11 | 12 | interface Props { 13 | title: string | React.ReactNode; 14 | currRegion: string; 15 | dataSource: RegionPricingType[]; 16 | } 17 | 18 | const RegionPricingTable: React.FC = ({ 19 | title, 20 | currRegion, 21 | dataSource, 22 | }) => { 23 | const { searchConfig } = useSearchConfigContext(); 24 | 25 | const columns: ColumnsType = [ 26 | { 27 | title: "Region", 28 | dataIndex: "region", 29 | render: (region) => 30 | region === currRegion ? ( 31 | region 32 | ) : ( 33 | 34 | {region} 35 | 36 | ), 37 | }, 38 | { 39 | title: () => ( 40 |

41 | Cost 42 | 46 | 47 | 48 |

49 | ), 50 | dataIndex: "hourlyUSD", 51 | align: "right", 52 | render: (hourlyUSD: number | undefined) => { 53 | if (hourlyUSD) { 54 | const cost = 55 | searchConfig.utilization * 56 | YearInHour * 57 | hourlyUSD * 58 | searchConfig.leaseLength; 59 | return ( 60 | ${withComma(getDigit(cost, 0))} 61 | ); 62 | } else { 63 | return Unavailable; 64 | } 65 | }, 66 | }, 67 | ]; 68 | 69 | return ( 70 |

{title}

} 79 | rowKey={(record) => record.region} 80 | components={{ 81 | body: { cell: TdCell }, 82 | }} 83 | /> 84 | ); 85 | }; 86 | 87 | export default RegionPricingTable; 88 | -------------------------------------------------------------------------------- /frontend/components/RelatedTable.tsx: -------------------------------------------------------------------------------- 1 | import { Table } from "antd"; 2 | import Link from "next/link"; 3 | import type { ColumnsType } from "antd/es/table"; 4 | import { QuestionMarkCircledIcon } from "@radix-ui/react-icons"; 5 | import Tooltip from "@/components/primitives/Tooltip"; 6 | import TdCell from "@/components/TdCell"; 7 | import { useSearchConfigContext } from "@/stores"; 8 | import { getDigit, YearInHour, withComma } from "@/utils"; 9 | import { RelatedType } from "@/types"; 10 | 11 | interface Props { 12 | title: string | React.ReactNode; 13 | instance: string; 14 | dataSource: RelatedType[]; 15 | } 16 | 17 | const RelatedTable: React.FC = ({ title, instance, dataSource }) => { 18 | const { searchConfig } = useSearchConfigContext(); 19 | const columns: ColumnsType = [ 20 | { 21 | title: "Name", 22 | dataIndex: "name", 23 | render: (name) => 24 | name === instance ? ( 25 | name 26 | ) : ( 27 | 28 | {name} 29 | 30 | ), 31 | }, 32 | { 33 | title: "CPU", 34 | dataIndex: "CPU", 35 | align: "right", 36 | render: (cpu: number) => {cpu}, 37 | shouldCellUpdate: () => false, 38 | }, 39 | { 40 | title: "Memory", 41 | dataIndex: "memory", 42 | align: "right", 43 | render: (memory: number) => ( 44 | {memory} GB 45 | ), 46 | shouldCellUpdate: () => false, 47 | }, 48 | { 49 | title: () => ( 50 |

51 | Cost 52 | 56 | 57 | 58 |

59 | ), 60 | dataIndex: "hourlyUSD", 61 | align: "right", 62 | render: (hourlyUSD: number | undefined) => { 63 | if (hourlyUSD) { 64 | const cost = 65 | searchConfig.utilization * 66 | YearInHour * 67 | hourlyUSD * 68 | searchConfig.leaseLength; 69 | return ( 70 | ${withComma(getDigit(cost, 0))} 71 | ); 72 | } else { 73 | return Unavailable; 74 | } 75 | }, 76 | }, 77 | ]; 78 | return ( 79 |

{title}

} 85 | rowKey={(record) => record.name} 86 | components={{ 87 | body: { cell: TdCell }, 88 | }} 89 | /> 90 | ); 91 | }; 92 | 93 | export default RelatedTable; 94 | -------------------------------------------------------------------------------- /frontend/components/SearchMenu.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import { Checkbox, Input, InputNumber, Slider, Divider } from "antd"; 3 | import { 4 | QuestionMarkCircledIcon, 5 | MagnifyingGlassIcon, 6 | } from "@radix-ui/react-icons"; 7 | import Tooltip from "@/components/primitives/Tooltip"; 8 | import { useSearchConfigContext } from "@/stores"; 9 | import { getIconPath } from "@/utils"; 10 | import { CloudProvider, EngineType, ChargeType, SearchBarType } from "@/types"; 11 | 12 | interface Props { 13 | type?: SearchBarType; 14 | hideProviders?: boolean; 15 | hideReservedChargePlan?: boolean; 16 | hideEngineType?: boolean; 17 | } 18 | 19 | const ProviderCheckbox = [ 20 | { 21 | key: "AWS", 22 | src: getIconPath("provider-aws.png"), 23 | width: 24, 24 | className: "pr-1", 25 | style: { transform: "scale(1)" }, 26 | }, 27 | { 28 | key: "GCP", 29 | src: getIconPath("provider-gcp.png"), 30 | width: 24, 31 | className: "", 32 | style: { transform: "scale(0.9)" }, 33 | }, 34 | ]; 35 | 36 | const EngineCheckbox = [ 37 | { 38 | key: "MYSQL", 39 | src: getIconPath("db-mysql.png"), 40 | width: 24, 41 | }, 42 | { 43 | key: "POSTGRES", 44 | src: getIconPath("db-postgres.png"), 45 | width: 24, 46 | }, 47 | ]; 48 | 49 | const SearchMenu: React.FC = ({ 50 | type = SearchBarType.DASHBOARD, 51 | hideProviders = false, 52 | hideReservedChargePlan = false, 53 | hideEngineType = false, 54 | }) => { 55 | const { searchConfig, update: updateSearchConfig } = useSearchConfigContext(); 56 | 57 | return ( 58 |
59 |
60 | {/* Cloud Providers */} 61 | {!hideProviders && 62 | type !== SearchBarType.INSTANCE_DETAIL && 63 | type !== SearchBarType.INSTANCE_COMPARISON && ( 64 | 68 | void updateSearchConfig( 69 | "cloudProvider", 70 | checkedValue as CloudProvider[] 71 | ) 72 | } 73 | > 74 | {ProviderCheckbox.map((provider) => ( 75 | 80 |
84 | 91 |
92 |
93 | ))} 94 | 95 |
96 | )} 97 | 98 | {/* Database Engine Types */} 99 | {!hideEngineType && ( 100 | 104 | void updateSearchConfig( 105 | "engineType", 106 | checkedValue as EngineType[] 107 | ) 108 | } 109 | > 110 | {EngineCheckbox.map((engine) => ( 111 | 112 |
113 | 120 |
121 |
122 | ))} 123 | 124 |
125 | )} 126 | 127 | {/* Charge Types */} 128 | 132 | void updateSearchConfig("chargeType", checkedValue as ChargeType[]) 133 | } 134 | > 135 | On Demand 136 | {(type === SearchBarType.DASHBOARD || 137 | type === SearchBarType.INSTANCE_COMPARISON) && 138 | !hideReservedChargePlan && ( 139 | Reserved 140 | )} 141 | 142 | 143 | {/* Min specification for Memory & CPU */} 144 | {type !== SearchBarType.INSTANCE_DETAIL && 145 | type !== SearchBarType.INSTANCE_COMPARISON && ( 146 |
147 |
148 | void updateSearchConfig("minCPU", value)} 155 | /> 156 |
157 |
158 | void updateSearchConfig("minRAM", value)} 165 | /> 166 |
167 |
168 | )} 169 | 170 | {/* Search Bar */} 171 |
172 | } 176 | value={searchConfig.keyword} 177 | onChange={(e) => void updateSearchConfig("keyword", e.target.value)} 178 | /> 179 |
180 |
181 | 182 | {/* Utilization & Lease Length Slider */} 183 |
184 |
185 |
186 | Utilization 187 | 192 | 193 | 194 | 195 | {Math.trunc(searchConfig.utilization * 100)}% 196 | 197 |
198 | void updateSearchConfig("utilization", value)} 205 | tooltip={{ formatter: null }} 206 | > 207 |
208 |
209 |
210 | Lease Length 211 | 212 | {searchConfig.leaseLength} Year 213 | 214 |
215 | void updateSearchConfig("leaseLength", value)} 222 | tooltip={{ formatter: null }} 223 | > 224 |
225 |
226 |
227 | ); 228 | }; 229 | 230 | export default SearchMenu; 231 | -------------------------------------------------------------------------------- /frontend/components/TdCell.tsx: -------------------------------------------------------------------------------- 1 | // Use a customized Cell component to avoid table's unnecessary on hover re-renders. 2 | const TdCell = (props: any) => { 3 | // Ant Design tables listen to onMouseEnter and onMouseLeave events to implement row hover styles (with rowSpan). 4 | // But reacting to too many onMouseEnter and onMouseLeave events will cause performance issues. 5 | // We can sacrifice a bit of style on hover as a compromise for better performance. 6 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 7 | const { onMouseEnter, onMouseLeave, ...restProps } = props; 8 | return
; 9 | }; 10 | 11 | export default TdCell; 12 | -------------------------------------------------------------------------------- /frontend/components/primitives/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"; 2 | 3 | type TooltipProps = { 4 | children: React.ReactNode; 5 | content: React.ReactNode; 6 | delayDuration?: number; 7 | }; 8 | 9 | const Tooltip: React.FC = ({ 10 | children, 11 | content, 12 | delayDuration, 13 | ...props 14 | }) => { 15 | return ( 16 | 17 | 18 | {children} 19 | 23 | {content} 24 | 25 | 26 | 27 | ); 28 | }; 29 | 30 | export default Tooltip; 31 | -------------------------------------------------------------------------------- /frontend/layouts/main.tsx: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import Header from "@/components/Header"; 3 | import Footer from "@/components/Footer"; 4 | import { useRouter } from "next/router"; 5 | 6 | type Props = { 7 | children: React.ReactNode; 8 | headTitle?: string; 9 | title: string; 10 | metaTagList?: { 11 | name: string; 12 | content: string; 13 | }[]; 14 | }; 15 | 16 | const baseURL = "https://www.dbcost.com"; 17 | 18 | const Main: React.FC = ({ children, headTitle, title, metaTagList }) => { 19 | // Concat a canonical URL, and remove trailing slash if any. 20 | const canonicalURL = (baseURL + useRouter().asPath).replace(/\/$/, ""); 21 | 22 | return ( 23 |
24 | 25 | 26 | {headTitle ?? "DB Cost | RDS & Cloud SQL Instance Pricing Sheet"} 27 | 28 | 29 | 30 | {metaTagList?.map(({ name, content }) => ( 31 | 32 | ))} 33 | 34 |
35 |
36 |
37 |

38 | {title} 39 |

40 | {children} 41 |
42 |
43 |
44 |
45 | ); 46 | }; 47 | 48 | export default Main; 49 | -------------------------------------------------------------------------------- /frontend/next-sitemap.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next-sitemap').IConfig} */ 2 | module.exports = { 3 | siteUrl: process.env.SITE_URL || "https://www.dbcost.com", 4 | generateRobotsTxt: true, 5 | // Currently dbcost will generate over 10,000 pages, so let's 6 | // split a huge sitemap into smaller pieces. 7 | sitemapSize: 5000, 8 | }; 9 | -------------------------------------------------------------------------------- /frontend/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | module.exports = { 3 | reactStrictMode: true, 4 | }; 5 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "next dev", 5 | "build": "next build", 6 | "post-build": "next-sitemap", 7 | "start": "next start", 8 | "lint": "next lint" 9 | }, 10 | "dependencies": { 11 | "@nivo/core": "^0.80.0", 12 | "@nivo/line": "^0.80.0", 13 | "@radix-ui/react-icons": "^1.1.1", 14 | "@radix-ui/react-tooltip": "^1.0.0", 15 | "antd": "^4.23.0", 16 | "lodash": "^4.17.21", 17 | "next": "14.2.22", 18 | "nextjs-bundle-analysis": "^0.5.0", 19 | "react": "18.3.1", 20 | "react-dom": "18.3.1", 21 | "slug": "^8.2.2" 22 | }, 23 | "devDependencies": { 24 | "@types/lodash": "^4.14.185", 25 | "@types/node": "18.19.54", 26 | "@types/react": "18.3.18", 27 | "@types/react-dom": "18.3.5", 28 | "@types/slug": "^5.0.3", 29 | "@typescript-eslint/eslint-plugin": "^5.37.0", 30 | "@typescript-eslint/parser": "^5.37.0", 31 | "autoprefixer": "^10.4.7", 32 | "eslint": "8.57.1", 33 | "eslint-config-next": "13.5.8", 34 | "eslint-config-prettier": "^8.5.0", 35 | "next-sitemap": "^3.1.29", 36 | "postcss": "^8.4.14", 37 | "tailwindcss": "^3.1.2", 38 | "typescript": "4.9.5" 39 | }, 40 | "nextBundleAnalysis": { 41 | "budget": 358400, 42 | "budgetPercentIncreaseRed": 20, 43 | "showDetails": true 44 | }, 45 | "engines": { 46 | "node": ">=18.18.0" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /frontend/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DBInstanceContextProvider, 3 | SearchConfigContextProvider, 4 | } from "@/stores"; 5 | // Import TailwindCSS here. 6 | import "../styles/globals.css"; 7 | import "antd/dist/antd.css"; 8 | import type { AppProps } from "next/app"; 9 | 10 | function MyApp({ Component, pageProps }: AppProps) { 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | } 19 | 20 | export default MyApp; 21 | -------------------------------------------------------------------------------- /frontend/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from "next/document"; 2 | import Script from "next/script"; 3 | 4 | export default function Document() { 5 | return ( 6 | 7 | 8 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /frontend/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback } from "react"; 2 | import type { NextPage, GetStaticProps } from "next"; 3 | import { Divider } from "antd"; 4 | import MainLayout from "@/layouts/main"; 5 | import CompareMenu from "@/components/CompareMenu"; 6 | import ButtonGroup from "@/components/ButtonGroup"; 7 | import RegionMenu from "@/components/RegionMenu"; 8 | import SearchMenu from "@/components/SearchMenu"; 9 | import CompareTable from "@/components/CompareTable"; 10 | import { useDBInstanceContext, useSearchConfigContext } from "@/stores"; 11 | import { getPrice, getRegionCode, getRegionName } from "@/utils"; 12 | import { 13 | DataSource, 14 | DBInstance, 15 | SearchConfig, 16 | SearchConfigDefault, 17 | tablePaginationConfig, 18 | } from "@/types"; 19 | 20 | interface Props { 21 | serverSideCompareTableData: DataSource[]; 22 | } 23 | 24 | const generateTableData = ( 25 | dbInstanceList: DBInstance[], 26 | searchConfig: SearchConfig 27 | ): DataSource[] => { 28 | let rowCount = 0; 29 | const dataSource: DataSource[] = []; 30 | const { 31 | cloudProvider, 32 | region, 33 | engineType, 34 | chargeType, 35 | minCPU, 36 | minRAM, 37 | utilization, 38 | leaseLength, 39 | keyword, 40 | } = searchConfig; 41 | 42 | // If any of these three below is empty, display no table row. 43 | if ( 44 | region.length === 0 || 45 | engineType.length === 0 || 46 | chargeType.length === 0 47 | ) { 48 | return []; 49 | } 50 | 51 | const cloudProviderSet = new Set(cloudProvider); 52 | const selectedRegionCodeSet = new Set( 53 | region.map((region) => getRegionCode(region)).flat() 54 | ); 55 | const engineSet = new Set(engineType); 56 | const chargeTypeSet = new Set(chargeType); 57 | 58 | // Process each db instance. 59 | dbInstanceList.forEach((dbInstance) => { 60 | if ( 61 | (minRAM !== undefined && Number(dbInstance.memory) < minRAM) || 62 | (minCPU !== undefined && Number(dbInstance.cpu) < minCPU) 63 | ) { 64 | return; 65 | } 66 | 67 | if (!cloudProviderSet.has(dbInstance.cloudProvider)) { 68 | return; 69 | } 70 | 71 | const selectedRegionList = dbInstance.regionList.filter((region) => 72 | selectedRegionCodeSet.has(region.code) 73 | ); 74 | 75 | if (selectedRegionList.length === 0) { 76 | return; 77 | } 78 | 79 | const dataRowList: DataSource[] = []; 80 | const dataRowMap: Map = new Map(); 81 | 82 | selectedRegionList.forEach((region) => { 83 | const selectedTermList = region.termList.filter( 84 | (term) => 85 | chargeTypeSet.has(term.type) && engineSet.has(term.databaseEngine) 86 | ); 87 | 88 | let basePriceMap = new Map(); 89 | selectedTermList.forEach((term) => { 90 | if (term.type === "OnDemand") { 91 | basePriceMap.set(term.databaseEngine, term.hourlyUSD); 92 | } 93 | }); 94 | 95 | const regionName = getRegionName(region.code); 96 | selectedTermList.forEach((term) => { 97 | const regionInstanceKey = `${dbInstance.name}::${region.code}::${term.databaseEngine}`; 98 | const newRow: DataSource = { 99 | // set this later 100 | id: -1, 101 | // We use :: for separation because AWS use . and GCP use - as separator. 102 | key: `${dbInstance.name}::${region.code}::${term.code}`, 103 | // set this later 104 | childCnt: 0, 105 | cloudProvider: dbInstance.cloudProvider, 106 | name: dbInstance.name, 107 | processor: dbInstance.processor, 108 | cpu: dbInstance.cpu, 109 | memory: dbInstance.memory, 110 | engineType: term.databaseEngine, 111 | commitment: { usd: term.commitmentUSD }, 112 | hourly: { usd: term.hourlyUSD }, 113 | leaseLength: term.payload?.leaseContractLength ?? "On Demand", 114 | // We store the region code for each provider, and show the user the actual region information. 115 | // e.g. AWS's us-east-1 and GCP's us-east-4 are refer to the same region (N. Virginia) 116 | region: regionName, 117 | baseHourly: basePriceMap.get(term.databaseEngine) as number, 118 | // set this later 119 | expectedCost: 0, 120 | }; 121 | newRow.expectedCost = getPrice(newRow, utilization, leaseLength); 122 | 123 | if (dataRowMap.has(regionInstanceKey)) { 124 | const existedDataRowList = dataRowMap.get( 125 | regionInstanceKey 126 | ) as DataSource[]; 127 | newRow.id = existedDataRowList[0].id; 128 | existedDataRowList.push(newRow); 129 | } else { 130 | rowCount++; 131 | newRow.id = rowCount; 132 | dataRowMap.set(regionInstanceKey, [newRow]); 133 | } 134 | }); 135 | }); 136 | 137 | dataRowMap.forEach((rows) => { 138 | rows.sort((a, b) => { 139 | // Sort rows according to the following criterion: 140 | // 1. On demand price goes first. 141 | // 2. Sort on expected cost in ascending order. 142 | if (a.leaseLength === "On Demand") { 143 | return -1; 144 | } else if (b.leaseLength === "On Demand") { 145 | return 1; 146 | } 147 | 148 | return a.expectedCost - b.expectedCost; 149 | }); 150 | rows.forEach((row) => { 151 | row.childCnt = rows.length; 152 | }); 153 | dataRowList.push(...rows); 154 | }); 155 | 156 | const searchKey = keyword?.toLowerCase(); 157 | if (searchKey) { 158 | // filter by keyword 159 | const filteredDataRowList: DataSource[] = dataRowList.filter( 160 | (row) => 161 | row.name.toLowerCase().includes(searchKey) || 162 | row.memory.toLowerCase().includes(searchKey) || 163 | row.processor.toLowerCase().includes(searchKey) || 164 | row.region.toLowerCase().includes(searchKey) 165 | ); 166 | dataSource.push(...filteredDataRowList); 167 | return; 168 | } 169 | 170 | dataSource.push(...dataRowList); 171 | }); 172 | 173 | return dataSource; 174 | }; 175 | 176 | const Home: NextPage = ({ serverSideCompareTableData }) => { 177 | const [dataSource, setDataSource] = useState( 178 | serverSideCompareTableData 179 | ); 180 | const { dbInstanceList, loadDBInstanceList, getAvailableRegionList } = 181 | useDBInstanceContext(); 182 | const { searchConfig } = useSearchConfigContext(); 183 | 184 | const memoizedGenerate = useCallback( 185 | (): DataSource[] => generateTableData(dbInstanceList, searchConfig), 186 | [dbInstanceList, searchConfig] 187 | ); 188 | 189 | useEffect(() => { 190 | loadDBInstanceList(); 191 | }, [loadDBInstanceList]); 192 | 193 | const availableRegionList = getAvailableRegionList(); 194 | 195 | return ( 196 | 211 |
212 | 213 | 214 | 215 | 216 | 217 | 222 |
223 |
224 | ); 225 | }; 226 | 227 | export default Home; 228 | 229 | export const getStaticProps: GetStaticProps = async () => { 230 | const data = (await import("@data")).default as DBInstance[]; 231 | // For SEO, showing the first page is enough. So we only need to 232 | // pass the first page of data to the page. Passing the whole large 233 | // `data` will make this page twice as large to reduce performance. 234 | const firstPageData = data.slice(0, tablePaginationConfig.defaultPageSize); 235 | 236 | return { 237 | props: { 238 | serverSideCompareTableData: generateTableData( 239 | firstPageData, 240 | SearchConfigDefault 241 | ), 242 | }, 243 | }; 244 | }; 245 | -------------------------------------------------------------------------------- /frontend/pages/provider/[provider]/engine/[engine].tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback } from "react"; 2 | import type { NextPage, GetStaticProps } from "next"; 3 | import MainLayout from "@/layouts/main"; 4 | import ButtonGroup from "@/components/ButtonGroup"; 5 | import RegionMenu from "@/components/RegionMenu"; 6 | import SearchMenu from "@/components/SearchMenu"; 7 | import CompareTable from "@/components/CompareTable"; 8 | import { useDBInstanceContext, useSearchConfigContext } from "@/stores"; 9 | import { getPrice, getRegionCode, getRegionName } from "@/utils"; 10 | import { 11 | DataSource, 12 | CloudProvider, 13 | DBInstance, 14 | SearchConfig, 15 | tablePaginationConfig, 16 | SearchConfigDefault, 17 | EngineType, 18 | } from "@/types"; 19 | 20 | interface Params { 21 | provider: string; 22 | engine: string; 23 | } 24 | 25 | interface Props { 26 | serverSideCompareTableData: DataSource[]; 27 | provider: string; 28 | engine: string; 29 | } 30 | 31 | const generateTableData = ( 32 | dbInstanceList: DBInstance[], 33 | searchConfig: SearchConfig 34 | ): DataSource[] => { 35 | let rowCount = 0; 36 | const dataSource: DataSource[] = []; 37 | const { 38 | cloudProvider, 39 | region, 40 | engineType, 41 | chargeType, 42 | minCPU, 43 | minRAM, 44 | utilization, 45 | leaseLength, 46 | keyword, 47 | } = searchConfig; 48 | 49 | // If any of these three below is empty, display no table row. 50 | if ( 51 | region.length === 0 || 52 | engineType.length === 0 || 53 | chargeType.length === 0 54 | ) { 55 | return []; 56 | } 57 | 58 | const cloudProviderSet = new Set(cloudProvider); 59 | const selectedRegionCodeSet = new Set( 60 | region.map((region) => getRegionCode(region)).flat() 61 | ); 62 | const engineSet = new Set(engineType); 63 | const chargeTypeSet = new Set(chargeType); 64 | 65 | // Process each db instance. 66 | dbInstanceList.forEach((dbInstance) => { 67 | if ( 68 | (minRAM !== undefined && Number(dbInstance.memory) < minRAM) || 69 | (minCPU !== undefined && Number(dbInstance.cpu) < minCPU) 70 | ) { 71 | return; 72 | } 73 | 74 | if (!cloudProviderSet.has(dbInstance.cloudProvider)) { 75 | return; 76 | } 77 | 78 | const selectedRegionList = dbInstance.regionList.filter((region) => 79 | selectedRegionCodeSet.has(region.code) 80 | ); 81 | 82 | if (selectedRegionList.length === 0) { 83 | return; 84 | } 85 | 86 | const dataRowList: DataSource[] = []; 87 | const dataRowMap: Map = new Map(); 88 | 89 | selectedRegionList.forEach((region) => { 90 | const selectedTermList = region.termList.filter( 91 | (term) => 92 | chargeTypeSet.has(term.type) && engineSet.has(term.databaseEngine) 93 | ); 94 | 95 | let basePriceMap = new Map(); 96 | selectedTermList.forEach((term) => { 97 | if (term.type === "OnDemand") { 98 | basePriceMap.set(term.databaseEngine, term.hourlyUSD); 99 | } 100 | }); 101 | 102 | const regionName = getRegionName(region.code); 103 | selectedTermList.forEach((term) => { 104 | const regionInstanceKey = `${dbInstance.name}::${region.code}::${term.databaseEngine}`; 105 | const newRow: DataSource = { 106 | // set this later 107 | id: -1, 108 | // We use :: for separation because AWS use . and GCP use - as separator. 109 | key: `${dbInstance.name}::${region.code}::${term.code}`, 110 | // set this later 111 | childCnt: 0, 112 | cloudProvider: dbInstance.cloudProvider, 113 | name: dbInstance.name, 114 | processor: dbInstance.processor, 115 | cpu: dbInstance.cpu, 116 | memory: dbInstance.memory, 117 | engineType: term.databaseEngine, 118 | commitment: { usd: term.commitmentUSD }, 119 | hourly: { usd: term.hourlyUSD }, 120 | leaseLength: term.payload?.leaseContractLength ?? "On Demand", 121 | // We store the region code for each provider, and show the user the actual region information. 122 | // e.g. AWS's us-east-1 and GCP's us-east-4 are refer to the same region (N. Virginia) 123 | region: regionName, 124 | baseHourly: basePriceMap.get(term.databaseEngine) as number, 125 | // set this later 126 | expectedCost: 0, 127 | }; 128 | newRow.expectedCost = getPrice(newRow, utilization, leaseLength); 129 | 130 | if (dataRowMap.has(regionInstanceKey)) { 131 | const existedDataRowList = dataRowMap.get( 132 | regionInstanceKey 133 | ) as DataSource[]; 134 | newRow.id = existedDataRowList[0].id; 135 | existedDataRowList.push(newRow); 136 | } else { 137 | rowCount++; 138 | newRow.id = rowCount; 139 | dataRowMap.set(regionInstanceKey, [newRow]); 140 | } 141 | }); 142 | }); 143 | 144 | dataRowMap.forEach((rows) => { 145 | rows.sort((a, b) => { 146 | // Sort rows according to the following criterion: 147 | // 1. On demand price goes first. 148 | // 2. Sort on expected cost in ascending order. 149 | if (a.leaseLength === "On Demand") { 150 | return -1; 151 | } else if (b.leaseLength === "On Demand") { 152 | return 1; 153 | } 154 | 155 | return a.expectedCost - b.expectedCost; 156 | }); 157 | rows.forEach((row) => { 158 | row.childCnt = rows.length; 159 | }); 160 | dataRowList.push(...rows); 161 | }); 162 | 163 | const searchKey = keyword?.toLowerCase(); 164 | if (searchKey) { 165 | // filter by keyword 166 | const filteredDataRowList: DataSource[] = dataRowList.filter( 167 | (row) => 168 | row.name.toLowerCase().includes(searchKey) || 169 | row.memory.toLowerCase().includes(searchKey) || 170 | row.processor.toLowerCase().includes(searchKey) || 171 | row.region.toLowerCase().includes(searchKey) 172 | ); 173 | dataSource.push(...filteredDataRowList); 174 | return; 175 | } 176 | 177 | dataSource.push(...dataRowList); 178 | }); 179 | 180 | return dataSource; 181 | }; 182 | 183 | const Provider: NextPage = ({ 184 | serverSideCompareTableData, 185 | provider, 186 | engine, 187 | }) => { 188 | const [dataSource, setDataSource] = useState( 189 | serverSideCompareTableData 190 | ); 191 | const { dbInstanceList, loadDBInstanceList, getAvailableRegionList } = 192 | useDBInstanceContext(); 193 | const { searchConfig, update: updateSearchConfig } = useSearchConfigContext(); 194 | 195 | const memoizedGenerate = useCallback( 196 | (): DataSource[] => generateTableData(dbInstanceList, searchConfig), 197 | [dbInstanceList, searchConfig] 198 | ); 199 | 200 | useEffect(() => { 201 | loadDBInstanceList(); 202 | }, [loadDBInstanceList]); 203 | 204 | const availableRegionList = getAvailableRegionList(); 205 | 206 | useEffect(() => { 207 | // Remove provider selector since the provider is determined. 208 | updateSearchConfig("cloudProvider", [ 209 | provider.toUpperCase() as CloudProvider, 210 | ]); 211 | // For engine type selector, ditto. 212 | updateSearchConfig("engineType", [engine.toUpperCase() as EngineType]); 213 | }, [engine, provider, updateSearchConfig]); 214 | 215 | return ( 216 | 237 |
238 | 239 | 240 | 241 | 242 | 248 |
249 |
250 | ); 251 | }; 252 | 253 | export default Provider; 254 | 255 | export const getStaticPaths = () => ({ 256 | paths: [ 257 | { params: { provider: "aws", engine: "mysql" } }, 258 | { params: { provider: "aws", engine: "postgres" } }, 259 | { params: { provider: "gcp", engine: "mysql" } }, 260 | { params: { provider: "gcp", engine: "postgres" } }, 261 | ], 262 | fallback: false, 263 | }); 264 | 265 | export const getStaticProps: GetStaticProps = async (context) => { 266 | const { provider, engine } = context.params as unknown as Params; 267 | const data = (await import("@data")).default as DBInstance[]; 268 | // For SEO, showing the first page is enough. So we only need to 269 | // pass the first page of data to the page. Passing the whole large 270 | // `data` will make this page twice as large and reduce performance. 271 | const firstPageData = data 272 | .filter((instance) => instance.cloudProvider === provider.toUpperCase()) 273 | .slice(0, tablePaginationConfig.defaultPageSize); 274 | 275 | return { 276 | props: { 277 | serverSideCompareTableData: generateTableData( 278 | firstPageData, 279 | SearchConfigDefault 280 | ), 281 | provider, 282 | engine, 283 | }, 284 | }; 285 | }; 286 | -------------------------------------------------------------------------------- /frontend/pages/provider/[provider]/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback } from "react"; 2 | import type { NextPage, GetStaticProps } from "next"; 3 | import MainLayout from "@/layouts/main"; 4 | import ButtonGroup from "@/components/ButtonGroup"; 5 | import RegionMenu from "@/components/RegionMenu"; 6 | import SearchMenu from "@/components/SearchMenu"; 7 | import CompareTable from "@/components/CompareTable"; 8 | import { useDBInstanceContext, useSearchConfigContext } from "@/stores"; 9 | import { getPrice, getRegionCode, getRegionName } from "@/utils"; 10 | import { 11 | DataSource, 12 | CloudProvider, 13 | DBInstance, 14 | SearchConfig, 15 | tablePaginationConfig, 16 | SearchConfigDefault, 17 | } from "@/types"; 18 | 19 | interface Params { 20 | provider: string; 21 | } 22 | 23 | interface Props { 24 | serverSideCompareTableData: DataSource[]; 25 | name: string; 26 | } 27 | 28 | const generateTableData = ( 29 | dbInstanceList: DBInstance[], 30 | searchConfig: SearchConfig 31 | ): DataSource[] => { 32 | let rowCount = 0; 33 | const dataSource: DataSource[] = []; 34 | const { 35 | cloudProvider, 36 | region, 37 | engineType, 38 | chargeType, 39 | minCPU, 40 | minRAM, 41 | utilization, 42 | leaseLength, 43 | keyword, 44 | } = searchConfig; 45 | 46 | // If any of these three below is empty, display no table row. 47 | if ( 48 | region.length === 0 || 49 | engineType.length === 0 || 50 | chargeType.length === 0 51 | ) { 52 | return []; 53 | } 54 | 55 | const cloudProviderSet = new Set(cloudProvider); 56 | const selectedRegionCodeSet = new Set( 57 | region.map((region) => getRegionCode(region)).flat() 58 | ); 59 | const engineSet = new Set(engineType); 60 | const chargeTypeSet = new Set(chargeType); 61 | 62 | // Process each db instance. 63 | dbInstanceList.forEach((dbInstance) => { 64 | if ( 65 | (minRAM !== undefined && Number(dbInstance.memory) < minRAM) || 66 | (minCPU !== undefined && Number(dbInstance.cpu) < minCPU) 67 | ) { 68 | return; 69 | } 70 | 71 | if (!cloudProviderSet.has(dbInstance.cloudProvider)) { 72 | return; 73 | } 74 | 75 | const selectedRegionList = dbInstance.regionList.filter((region) => 76 | selectedRegionCodeSet.has(region.code) 77 | ); 78 | 79 | if (selectedRegionList.length === 0) { 80 | return; 81 | } 82 | 83 | const dataRowList: DataSource[] = []; 84 | const dataRowMap: Map = new Map(); 85 | 86 | selectedRegionList.forEach((region) => { 87 | const selectedTermList = region.termList.filter( 88 | (term) => 89 | chargeTypeSet.has(term.type) && engineSet.has(term.databaseEngine) 90 | ); 91 | 92 | let basePriceMap = new Map(); 93 | selectedTermList.forEach((term) => { 94 | if (term.type === "OnDemand") { 95 | basePriceMap.set(term.databaseEngine, term.hourlyUSD); 96 | } 97 | }); 98 | 99 | const regionName = getRegionName(region.code); 100 | selectedTermList.forEach((term) => { 101 | const regionInstanceKey = `${dbInstance.name}::${region.code}::${term.databaseEngine}`; 102 | const newRow: DataSource = { 103 | // set this later 104 | id: -1, 105 | // We use :: for separation because AWS use . and GCP use - as separator. 106 | key: `${dbInstance.name}::${region.code}::${term.code}`, 107 | // set this later 108 | childCnt: 0, 109 | cloudProvider: dbInstance.cloudProvider, 110 | name: dbInstance.name, 111 | processor: dbInstance.processor, 112 | cpu: dbInstance.cpu, 113 | memory: dbInstance.memory, 114 | engineType: term.databaseEngine, 115 | commitment: { usd: term.commitmentUSD }, 116 | hourly: { usd: term.hourlyUSD }, 117 | leaseLength: term.payload?.leaseContractLength ?? "On Demand", 118 | // We store the region code for each provider, and show the user the actual region information. 119 | // e.g. AWS's us-east-1 and GCP's us-east-4 are refer to the same region (N. Virginia) 120 | region: regionName, 121 | baseHourly: basePriceMap.get(term.databaseEngine) as number, 122 | // set this later 123 | expectedCost: 0, 124 | }; 125 | newRow.expectedCost = getPrice(newRow, utilization, leaseLength); 126 | 127 | if (dataRowMap.has(regionInstanceKey)) { 128 | const existedDataRowList = dataRowMap.get( 129 | regionInstanceKey 130 | ) as DataSource[]; 131 | newRow.id = existedDataRowList[0].id; 132 | existedDataRowList.push(newRow); 133 | } else { 134 | rowCount++; 135 | newRow.id = rowCount; 136 | dataRowMap.set(regionInstanceKey, [newRow]); 137 | } 138 | }); 139 | }); 140 | 141 | dataRowMap.forEach((rows) => { 142 | rows.sort((a, b) => { 143 | // Sort rows according to the following criterion: 144 | // 1. On demand price goes first. 145 | // 2. Sort on expected cost in ascending order. 146 | if (a.leaseLength === "On Demand") { 147 | return -1; 148 | } else if (b.leaseLength === "On Demand") { 149 | return 1; 150 | } 151 | 152 | return a.expectedCost - b.expectedCost; 153 | }); 154 | rows.forEach((row) => { 155 | row.childCnt = rows.length; 156 | }); 157 | dataRowList.push(...rows); 158 | }); 159 | 160 | const searchKey = keyword?.toLowerCase(); 161 | if (searchKey) { 162 | // filter by keyword 163 | const filteredDataRowList: DataSource[] = dataRowList.filter( 164 | (row) => 165 | row.name.toLowerCase().includes(searchKey) || 166 | row.memory.toLowerCase().includes(searchKey) || 167 | row.processor.toLowerCase().includes(searchKey) || 168 | row.region.toLowerCase().includes(searchKey) 169 | ); 170 | dataSource.push(...filteredDataRowList); 171 | return; 172 | } 173 | 174 | dataSource.push(...dataRowList); 175 | }); 176 | 177 | return dataSource; 178 | }; 179 | 180 | const Provider: NextPage = ({ serverSideCompareTableData, name }) => { 181 | const [dataSource, setDataSource] = useState( 182 | serverSideCompareTableData 183 | ); 184 | const { dbInstanceList, loadDBInstanceList, getAvailableRegionList } = 185 | useDBInstanceContext(); 186 | const { searchConfig, update: updateSearchConfig } = useSearchConfigContext(); 187 | 188 | const memoizedGenerate = useCallback( 189 | (): DataSource[] => generateTableData(dbInstanceList, searchConfig), 190 | [dbInstanceList, searchConfig] 191 | ); 192 | 193 | useEffect(() => { 194 | loadDBInstanceList(); 195 | }, [loadDBInstanceList]); 196 | 197 | const availableRegionList = getAvailableRegionList(); 198 | 199 | useEffect(() => { 200 | // Remove provider selector since the provider is determined. 201 | updateSearchConfig("cloudProvider", [name.toUpperCase() as CloudProvider]); 202 | updateSearchConfig("chargeType", ["OnDemand"]); 203 | }, [name, updateSearchConfig]); 204 | 205 | return ( 206 | 225 |
226 | 227 | 228 | 229 | 230 | 236 |
237 |
238 | ); 239 | }; 240 | 241 | export default Provider; 242 | 243 | export const getStaticPaths = () => ({ 244 | paths: [{ params: { provider: "aws" } }, { params: { provider: "gcp" } }], 245 | fallback: false, 246 | }); 247 | 248 | export const getStaticProps: GetStaticProps = async (context) => { 249 | const { provider: providerName } = context.params as unknown as Params; 250 | const data = (await import("@data")).default as DBInstance[]; 251 | // For SEO, showing the first page is enough. So we only need to 252 | // pass the first page of data to the page. Passing the whole large 253 | // `data` will make this page twice as large and reduce performance. 254 | const firstPageData = data 255 | .filter((instance) => instance.cloudProvider === providerName.toUpperCase()) 256 | .slice(0, tablePaginationConfig.defaultPageSize); 257 | 258 | return { 259 | props: { 260 | serverSideCompareTableData: generateTableData( 261 | firstPageData, 262 | SearchConfigDefault 263 | ), 264 | name: providerName, 265 | }, 266 | }; 267 | }; 268 | -------------------------------------------------------------------------------- /frontend/pages/region/[region].tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useCallback } from "react"; 2 | import Link from "next/link"; 3 | import type { NextPage, GetStaticProps, GetStaticPaths } from "next"; 4 | import { Divider } from "antd"; 5 | import slug from "slug"; 6 | import MainLayout from "@/layouts/main"; 7 | import ButtonGroup from "@/components/ButtonGroup"; 8 | import SearchMenu from "@/components/SearchMenu"; 9 | import CompareTable from "@/components/CompareTable"; 10 | import LineChart from "@/components/LineChart"; 11 | import { useDBInstanceContext, useSearchConfigContext } from "@/stores"; 12 | import { 13 | getPrice, 14 | getRegionName, 15 | getRegionPrefix, 16 | getRegionListByPrefix, 17 | regionCodeNameSlugMap, 18 | } from "@/utils"; 19 | import { 20 | CloudProvider, 21 | DataSource, 22 | PageType, 23 | SearchBarType, 24 | DBInstance, 25 | SearchConfig, 26 | Region, 27 | AvailableRegion, 28 | SearchConfigDefault, 29 | tablePaginationConfig, 30 | } from "@/types"; 31 | 32 | interface Params { 33 | region: string; 34 | } 35 | 36 | interface Props { 37 | serverSideCompareTableData: DataSource[]; 38 | codeList: string[]; 39 | name: string; 40 | urlPattern: string; 41 | providerList: CloudProvider[]; 42 | } 43 | 44 | const generateTableData = ( 45 | dbInstanceList: DBInstance[], 46 | searchConfig: SearchConfig, 47 | codeList: string[] 48 | ): DataSource[] => { 49 | let rowCount = 0; 50 | const dataSource: DataSource[] = []; 51 | const { 52 | cloudProvider, 53 | engineType, 54 | chargeType, 55 | minCPU, 56 | minRAM, 57 | utilization, 58 | leaseLength, 59 | keyword, 60 | } = searchConfig; 61 | 62 | // If any of these below is empty, display no table row. 63 | if (engineType.length === 0 || chargeType.length === 0) { 64 | return []; 65 | } 66 | 67 | const cloudProviderSet = new Set(cloudProvider); 68 | const selectedRegionCodeSet = new Set(codeList); 69 | const engineSet = new Set(engineType); 70 | const chargeTypeSet = new Set(chargeType); 71 | 72 | // Process each db instance. 73 | dbInstanceList.forEach((dbInstance) => { 74 | if ( 75 | (minRAM !== undefined && Number(dbInstance.memory) < minRAM) || 76 | (minCPU !== undefined && Number(dbInstance.cpu) < minCPU) 77 | ) { 78 | return; 79 | } 80 | 81 | if (!cloudProviderSet.has(dbInstance.cloudProvider)) { 82 | return; 83 | } 84 | 85 | const selectedRegionList = dbInstance.regionList.filter((region) => 86 | selectedRegionCodeSet.has(region.code) 87 | ); 88 | 89 | if (selectedRegionList.length === 0) { 90 | return; 91 | } 92 | 93 | const dataRowList: DataSource[] = []; 94 | const dataRowMap: Map = new Map(); 95 | 96 | selectedRegionList.forEach((region) => { 97 | const selectedTermList = region.termList.filter( 98 | (term) => 99 | chargeTypeSet.has(term.type) && engineSet.has(term.databaseEngine) 100 | ); 101 | 102 | let basePriceMap = new Map(); 103 | selectedTermList.forEach((term) => { 104 | if (term.type === "OnDemand") { 105 | basePriceMap.set(term.databaseEngine, term.hourlyUSD); 106 | } 107 | }); 108 | 109 | const regionName = getRegionName(region.code); 110 | selectedTermList.forEach((term) => { 111 | const regionInstanceKey = `${dbInstance.name}::${region.code}::${term.databaseEngine}`; 112 | const newRow: DataSource = { 113 | // set this later 114 | id: -1, 115 | // We use :: for separation because AWS use . and GCP use - as separator. 116 | key: `${dbInstance.name}::${region.code}::${term.code}`, 117 | // set this later 118 | childCnt: 0, 119 | cloudProvider: dbInstance.cloudProvider, 120 | name: dbInstance.name, 121 | processor: dbInstance.processor, 122 | cpu: dbInstance.cpu, 123 | memory: dbInstance.memory, 124 | engineType: term.databaseEngine, 125 | commitment: { usd: term.commitmentUSD }, 126 | hourly: { usd: term.hourlyUSD }, 127 | leaseLength: term.payload?.leaseContractLength ?? "On Demand", 128 | // We store the region code for each provider, and show the user the actual region information. 129 | // e.g. AWS's us-east-1 and GCP's us-east-4 are refer to the same region (N. Virginia) 130 | region: regionName, 131 | baseHourly: basePriceMap.get(term.databaseEngine) as number, 132 | // set this later 133 | expectedCost: 0, 134 | }; 135 | newRow.expectedCost = getPrice(newRow, utilization, leaseLength); 136 | 137 | if (dataRowMap.has(regionInstanceKey)) { 138 | const existedDataRowList = dataRowMap.get( 139 | regionInstanceKey 140 | ) as DataSource[]; 141 | newRow.id = existedDataRowList[0].id; 142 | existedDataRowList.push(newRow); 143 | } else { 144 | rowCount++; 145 | newRow.id = rowCount; 146 | dataRowMap.set(regionInstanceKey, [newRow]); 147 | } 148 | }); 149 | }); 150 | 151 | dataRowMap.forEach((rows) => { 152 | rows.sort((a, b) => { 153 | // Sort rows according to the following criterion: 154 | // 1. On demand price goes first. 155 | // 2. Sort on expected cost in ascending order. 156 | if (a.leaseLength === "On Demand") { 157 | return -1; 158 | } else if (b.leaseLength === "On Demand") { 159 | return 1; 160 | } 161 | 162 | return a.expectedCost - b.expectedCost; 163 | }); 164 | rows.forEach((row) => { 165 | row.childCnt = rows.length; 166 | }); 167 | dataRowList.push(...rows); 168 | }); 169 | 170 | const searchKey = keyword?.toLowerCase(); 171 | if (searchKey) { 172 | // filter by keyword 173 | const filteredDataRowList: DataSource[] = dataRowList.filter( 174 | (row) => 175 | row.name.toLowerCase().includes(searchKey) || 176 | row.memory.toLowerCase().includes(searchKey) || 177 | row.processor.toLowerCase().includes(searchKey) || 178 | row.region.toLowerCase().includes(searchKey) 179 | ); 180 | dataSource.push(...filteredDataRowList); 181 | return; 182 | } 183 | 184 | dataSource.push(...dataRowList); 185 | }); 186 | 187 | return dataSource; 188 | }; 189 | 190 | const Region: NextPage = ({ 191 | serverSideCompareTableData, 192 | codeList, 193 | name, 194 | providerList, 195 | }) => { 196 | const [dataSource, setDataSource] = useState( 197 | serverSideCompareTableData 198 | ); 199 | const { dbInstanceList, loadDBInstanceList } = useDBInstanceContext(); 200 | const { searchConfig, update: updateSearchConfig } = useSearchConfigContext(); 201 | 202 | const memoizedGenerate = useCallback( 203 | (): DataSource[] => 204 | generateTableData(dbInstanceList, searchConfig, codeList), 205 | [codeList, dbInstanceList, searchConfig] 206 | ); 207 | 208 | useEffect(() => { 209 | // Set default searchConfig in instance detail page. 210 | updateSearchConfig("chargeType", ["OnDemand"]); 211 | }, [updateSearchConfig]); 212 | 213 | useEffect(() => { 214 | loadDBInstanceList(); 215 | }, [loadDBInstanceList]); 216 | 217 | return ( 218 | 232 |
233 | 234 | 238 | 245 |
246 | 247 |
248 |
249 |

250 | Regions available in {getRegionPrefix(name)} 251 |

252 |
253 | {name} 254 | {getRegionListByPrefix(getRegionPrefix(name)) 255 | .filter((region) => region !== name) 256 | .map((region) => ( 257 | 258 | 259 | 264 | {region} 265 | 266 | 267 | ))} 268 |
269 |
270 |
271 |
272 | ); 273 | }; 274 | 275 | export default Region; 276 | 277 | export const getStaticPaths: GetStaticPaths = async () => { 278 | const data = (await import("@data")).default as DBInstance[]; 279 | 280 | const usedRegionCodeSet = new Set(); 281 | data.forEach((instance) => { 282 | instance.regionList.forEach((region) => { 283 | // Only pre-generate regions listed in regionCodeNameSlugMap. 284 | if (regionCodeNameSlugMap.some(([code]) => code === region.code)) { 285 | usedRegionCodeSet.add(region.code); 286 | } 287 | }); 288 | }); 289 | 290 | return { 291 | paths: Array.from(usedRegionCodeSet).map((regionCode) => ({ 292 | params: { 293 | region: slug(getRegionName(regionCode)), 294 | }, 295 | })), 296 | fallback: false, 297 | }; 298 | }; 299 | 300 | export const getStaticProps: GetStaticProps = async (context) => { 301 | const { region: regionSlug } = context.params as unknown as Params; 302 | const data = (await import("@data")).default as DBInstance[]; 303 | 304 | // Only pass the first page of data on SSG to reduce page size. 305 | const firstPageData = data.slice(0, tablePaginationConfig.defaultPageSize); 306 | const regionList = regionCodeNameSlugMap.filter( 307 | ([, , slug]) => slug === regionSlug 308 | ); 309 | const name = regionList[0][1]; 310 | 311 | const regionMap = new Map>(); 312 | data.forEach((db) => { 313 | db.regionList.forEach((region: Region) => { 314 | const regionName = getRegionName(region.code); 315 | if (!regionMap.has(regionName)) { 316 | const newMap = new Map(); 317 | regionMap.set(regionName, newMap); 318 | } 319 | 320 | regionMap.get(regionName)?.set(db.cloudProvider, region.code); 321 | }); 322 | }); 323 | 324 | const availableRegionList: AvailableRegion[] = []; 325 | regionMap.forEach((providerCode, regionName) => { 326 | availableRegionList.push({ 327 | name: regionName, 328 | providerCode: providerCode, 329 | }); 330 | }); 331 | 332 | const region = availableRegionList.find((region) => region.name === name); 333 | 334 | return { 335 | props: { 336 | serverSideCompareTableData: generateTableData( 337 | firstPageData, 338 | SearchConfigDefault, 339 | Array.from(region?.providerCode.values() || []) 340 | ), 341 | codeList: Array.from(region?.providerCode.values() || []), 342 | name: region?.name, 343 | providerList: Array.from(region?.providerCode.keys() || []), 344 | }, 345 | }; 346 | }; 347 | -------------------------------------------------------------------------------- /frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /frontend/public/.gitignore: -------------------------------------------------------------------------------- 1 | robots.txt 2 | sitemap*.xml 3 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bytebase/dbcost/2671e635b501ed756547864277c5c4cca7322a13/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/fonts/xkcd.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bytebase/dbcost/2671e635b501ed756547864277c5c4cca7322a13/frontend/public/fonts/xkcd.ttf -------------------------------------------------------------------------------- /frontend/public/icons/bytebase-cncf.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /frontend/public/icons/db-mysql.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bytebase/dbcost/2671e635b501ed756547864277c5c4cca7322a13/frontend/public/icons/db-mysql.png -------------------------------------------------------------------------------- /frontend/public/icons/db-oracle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bytebase/dbcost/2671e635b501ed756547864277c5c4cca7322a13/frontend/public/icons/db-oracle.png -------------------------------------------------------------------------------- /frontend/public/icons/db-postgres.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bytebase/dbcost/2671e635b501ed756547864277c5c4cca7322a13/frontend/public/icons/db-postgres.png -------------------------------------------------------------------------------- /frontend/public/icons/db-sqlserver.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bytebase/dbcost/2671e635b501ed756547864277c5c4cca7322a13/frontend/public/icons/db-sqlserver.png -------------------------------------------------------------------------------- /frontend/public/icons/dbcost-logo-full.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bytebase/dbcost/2671e635b501ed756547864277c5c4cca7322a13/frontend/public/icons/dbcost-logo-full.webp -------------------------------------------------------------------------------- /frontend/public/icons/provider-aws.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bytebase/dbcost/2671e635b501ed756547864277c5c4cca7322a13/frontend/public/icons/provider-aws.png -------------------------------------------------------------------------------- /frontend/public/icons/provider-gcp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bytebase/dbcost/2671e635b501ed756547864277c5c4cca7322a13/frontend/public/icons/provider-gcp.png -------------------------------------------------------------------------------- /frontend/public/mysql-vs-pg.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bytebase/dbcost/2671e635b501ed756547864277c5c4cca7322a13/frontend/public/mysql-vs-pg.webp -------------------------------------------------------------------------------- /frontend/public/sqlchat.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bytebase/dbcost/2671e635b501ed756547864277c5c4cca7322a13/frontend/public/sqlchat.webp -------------------------------------------------------------------------------- /frontend/public/star-history.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bytebase/dbcost/2671e635b501ed756547864277c5c4cca7322a13/frontend/public/star-history.webp -------------------------------------------------------------------------------- /frontend/stores/dbInstanceContext.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useState, 3 | createContext, 4 | useContext, 5 | useMemo, 6 | useCallback, 7 | } from "react"; 8 | import { getRegionName } from "@/utils"; 9 | import { AvailableRegion, DBInstance, DBInstanceId, Region } from "@/types"; 10 | 11 | const DBInstanceContext = createContext({ 12 | dbInstanceList: [], 13 | // getters 14 | getDBInstanceById: () => null, 15 | getAvailableRegionList: () => [], 16 | getAvailableRegionSet: () => new Set(), 17 | // actions 18 | loadDBInstanceList: () => null, 19 | }); 20 | 21 | interface ProviderProps { 22 | children: React.ReactNode; 23 | } 24 | 25 | interface ContextStore { 26 | dbInstanceList: DBInstance[]; 27 | // getters 28 | getDBInstanceById: (id: DBInstanceId) => DBInstance | null; 29 | getAvailableRegionList: () => AvailableRegion[]; 30 | getAvailableRegionSet: () => Set; 31 | // actions 32 | loadDBInstanceList: () => void; 33 | } 34 | 35 | export const DBInstanceContextProvider: React.FC = ({ 36 | children, 37 | }) => { 38 | const [dbInstanceList, setDBInstanceList] = useState([]); 39 | 40 | const { Provider } = DBInstanceContext; 41 | 42 | const getDBInstanceById = useCallback( 43 | (dbInstanceId: DBInstanceId): DBInstance | null => { 44 | const dbInstance = dbInstanceList.filter((db) => { 45 | db.id === dbInstanceId; 46 | }); 47 | if (dbInstance.length === 0) { 48 | return null; 49 | } 50 | return dbInstance[0]; 51 | }, 52 | [dbInstanceList] 53 | ); 54 | 55 | const getAvailableRegionList = useCallback((): AvailableRegion[] => { 56 | const regionMap = new Map>(); 57 | dbInstanceList.forEach((db) => { 58 | db.regionList.forEach((region: Region) => { 59 | const regionName = getRegionName(region.code); 60 | if (!regionMap.has(regionName)) { 61 | const newMap = new Map(); 62 | regionMap.set(regionName, newMap); 63 | } 64 | 65 | regionMap.get(regionName)?.set(db.cloudProvider, region.code); 66 | }); 67 | }); 68 | 69 | const availableRegionList: AvailableRegion[] = []; 70 | regionMap.forEach((providerCode, regionName) => { 71 | availableRegionList.push({ 72 | name: regionName, 73 | providerCode: providerCode, 74 | }); 75 | }); 76 | 77 | return availableRegionList.sort( 78 | (a: AvailableRegion, b: AvailableRegion) => { 79 | if (a.name.includes("Other")) { 80 | return 1; 81 | } else if (b.name.includes("Other")) { 82 | return -1; 83 | } 84 | return a.name.localeCompare(b.name); 85 | } 86 | ); 87 | }, [dbInstanceList]); 88 | 89 | const getAvailableRegionSet = useCallback((): Set => { 90 | const regionSet = new Set(); 91 | dbInstanceList.forEach((db) => { 92 | db.regionList.forEach((region) => { 93 | regionSet.add(getRegionName(region.code)); 94 | }); 95 | }); 96 | 97 | return regionSet; 98 | }, [dbInstanceList]); 99 | 100 | const loadDBInstanceList = useCallback(async () => { 101 | const data = (await import("@data")).default as DBInstance[]; 102 | setDBInstanceList(data); 103 | }, []); 104 | 105 | const dbInstanceContextStore: ContextStore = useMemo( 106 | () => ({ 107 | dbInstanceList, 108 | getDBInstanceById, 109 | getAvailableRegionList, 110 | getAvailableRegionSet, 111 | loadDBInstanceList, 112 | }), 113 | [ 114 | dbInstanceList, 115 | getAvailableRegionList, 116 | getAvailableRegionSet, 117 | getDBInstanceById, 118 | loadDBInstanceList, 119 | ] 120 | ); 121 | 122 | return {children}; 123 | }; 124 | 125 | export const useDBInstanceContext = () => 126 | useContext(DBInstanceContext); 127 | -------------------------------------------------------------------------------- /frontend/stores/index.ts: -------------------------------------------------------------------------------- 1 | export * from "@/stores/dbInstanceContext"; 2 | export * from "@/stores/searchConfigContext"; 3 | -------------------------------------------------------------------------------- /frontend/stores/searchConfigContext.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useState, 3 | useEffect, 4 | createContext, 5 | useContext, 6 | useMemo, 7 | useCallback, 8 | } from "react"; 9 | import { useRouter } from "next/router"; 10 | import isEmpty from "lodash/isEmpty"; 11 | import { processParsedUrlQuery } from "@/utils"; 12 | import { SearchConfig, SearchConfigDefault, SearchConfigEmpty } from "@/types"; 13 | 14 | const SearchConfigContext = createContext({ 15 | searchConfig: SearchConfigDefault, 16 | isFiltering: () => false, 17 | reset: () => null, 18 | clear: () => null, 19 | update: () => null, 20 | }); 21 | 22 | interface ProviderProps { 23 | children: React.ReactNode; 24 | } 25 | 26 | interface ContextStore { 27 | searchConfig: SearchConfig; 28 | isFiltering: () => boolean; 29 | reset: () => void; 30 | clear: () => void; 31 | update: ( 32 | field: K, 33 | value: SearchConfig[K] 34 | ) => void; 35 | } 36 | 37 | // For now, we only enable query binding at the main dashboard. 38 | const enableQueryBinding = (pathname: string): boolean => pathname === "/"; 39 | 40 | export const SearchConfigContextProvider: React.FC = ({ 41 | children, 42 | }) => { 43 | const router = useRouter(); 44 | const [searchConfig, setSearchConfig] = 45 | useState(SearchConfigDefault); 46 | 47 | useEffect(() => { 48 | if (enableQueryBinding(router.pathname) && router.query) { 49 | if (isEmpty(router.query)) { 50 | // If no query specified, use default search config. 51 | setSearchConfig(SearchConfigDefault); 52 | } else { 53 | // Else, use as query specified. 54 | setSearchConfig({ 55 | ...SearchConfigEmpty, 56 | ...processParsedUrlQuery(router.query), 57 | }); 58 | } 59 | } 60 | }, [router.pathname, router.query]); 61 | 62 | const { Provider } = SearchConfigContext; 63 | 64 | const isFiltering = useCallback( 65 | (): boolean => 66 | searchConfig.keyword.length > 0 || 67 | searchConfig.minCPU > 0 || 68 | searchConfig.minRAM > 0 || 69 | searchConfig.chargeType.length < 2, 70 | [ 71 | searchConfig.chargeType.length, 72 | searchConfig.keyword.length, 73 | searchConfig.minCPU, 74 | searchConfig.minRAM, 75 | ] 76 | ); 77 | 78 | const reset = useCallback(() => { 79 | if (enableQueryBinding(router.pathname)) { 80 | router.push({ pathname: router.pathname, query: {} }, undefined, { 81 | shallow: true, 82 | }); 83 | } 84 | setSearchConfig({ ...SearchConfigDefault }); 85 | }, [router]); 86 | 87 | const clear = useCallback(() => { 88 | setSearchConfig({ 89 | chargeType: [], 90 | cloudProvider: [], 91 | engineType: [], 92 | keyword: "", 93 | minCPU: 0, 94 | minRAM: 0, 95 | region: [], 96 | utilization: SearchConfigDefault.utilization, 97 | leaseLength: SearchConfigDefault.leaseLength, 98 | }); 99 | }, []); 100 | 101 | const update = useCallback( 102 | (field: K, value: SearchConfig[K]) => { 103 | setSearchConfig((state) => { 104 | const newState = { 105 | ...state, 106 | [field]: value, 107 | }; 108 | 109 | if (enableQueryBinding(router.pathname)) { 110 | // update URL query string after search config change 111 | router.push( 112 | { 113 | pathname: router.pathname, 114 | query: newState, 115 | }, 116 | undefined, 117 | // use shallow-routing to avoid page reload 118 | { shallow: true } 119 | ); 120 | } 121 | 122 | return newState; 123 | }); 124 | }, 125 | [router] 126 | ); 127 | 128 | const searchConfigContextStore: ContextStore = useMemo( 129 | () => ({ 130 | searchConfig, 131 | isFiltering, 132 | reset, 133 | clear, 134 | update, 135 | }), 136 | [clear, isFiltering, reset, searchConfig, update] 137 | ); 138 | 139 | return {children}; 140 | }; 141 | 142 | export const useSearchConfigContext = () => 143 | useContext(SearchConfigContext); 144 | -------------------------------------------------------------------------------- /frontend/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @font-face { 6 | font-family: "xkcd"; 7 | src: url("/fonts/xkcd.ttf"); 8 | } 9 | -------------------------------------------------------------------------------- /frontend/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | "./pages/**/*.{js,ts,jsx,tsx}", 5 | "./layouts/**/*.{js,ts,jsx,tsx}", 6 | "./components/**/*.{js,ts,jsx,tsx}", 7 | ], 8 | theme: { 9 | extend: { 10 | gridTemplateColumns: { 11 | // equivalent to grid-cols-[auto,auto,auto,auto,auto,auto,auto] 12 | "7-auto": "repeat(7, auto)", 13 | }, 14 | }, 15 | }, 16 | plugins: [], 17 | }; 18 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "baseUrl": ".", 18 | "paths": { 19 | "@/*": ["./*"], 20 | "@data": ["../data/dbInstance.json"] 21 | }, 22 | "downlevelIteration": true 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /frontend/types/common/chart.ts: -------------------------------------------------------------------------------- 1 | export const monthDays = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; 2 | 3 | export const commonProperties = { 4 | width: 1150, 5 | height: 800, 6 | margin: { top: 20, right: 20, bottom: 60, left: 80 }, 7 | animate: true, 8 | theme: { 9 | fontFamily: "xkcd", 10 | fontSize: 16, 11 | axis: { 12 | legend: { 13 | text: { 14 | fontSize: 16, 15 | }, 16 | }, 17 | }, 18 | tooltip: { 19 | container: { 20 | fontFamily: "xkcd", 21 | }, 22 | }, 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /frontend/types/common/common.ts: -------------------------------------------------------------------------------- 1 | export type RowStatus = "ARCHIVED" | "NORMAL"; 2 | 3 | export type Currency = "USD"; 4 | 5 | export type EngineType = "MYSQL" | "POSTGRES" | "ORACLE" | "SQLSERVER"; 6 | 7 | // "" meas empty cloud provider 8 | export type CloudProvider = "AWS" | "ALIYUN" | "GCP" | ""; 9 | 10 | export const isValidCloudProvider = (providerList: string[]): boolean => { 11 | for (const provider of providerList) { 12 | if (provider !== "AWS" && provider !== "GCP" && provider !== "") { 13 | return false; 14 | } 15 | } 16 | 17 | return true; 18 | }; 19 | 20 | export const isValidEngineType = (engineTypeList: string[]): boolean => { 21 | for (const engineType of engineTypeList) { 22 | if ( 23 | engineType !== "MYSQL" && 24 | engineType !== "POSTGRES" && 25 | engineType !== "ORACLE" && 26 | engineType !== "SQLSERVER" 27 | ) { 28 | return false; 29 | } 30 | } 31 | 32 | return true; 33 | }; 34 | -------------------------------------------------------------------------------- /frontend/types/common/id.ts: -------------------------------------------------------------------------------- 1 | export type NumId = number; 2 | export type StrId = string; 3 | 4 | export type ContributorId = NumId; 5 | export type RegionId = NumId; 6 | export type DBInstanceId = NumId; 7 | 8 | export type ExternalId = StrId; 9 | -------------------------------------------------------------------------------- /frontend/types/common/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./common"; 2 | export * from "./id"; 3 | export * from "./chart"; 4 | -------------------------------------------------------------------------------- /frontend/types/dbInstance.ts: -------------------------------------------------------------------------------- 1 | import { RowStatus } from "./common/common"; 2 | import { 3 | DBInstanceId, 4 | ExternalId, 5 | ContributorId, 6 | CloudProvider, 7 | } from "./common"; 8 | import { Region } from "./region"; 9 | 10 | export type InstanceFamily = "GENERAL" | "MEMORY"; 11 | 12 | export type DBInstance = { 13 | id: DBInstanceId; 14 | externalId: ExternalId; 15 | rowStatus: RowStatus; 16 | creatorId: ContributorId; 17 | updaterId: ContributorId; 18 | updatedTs: number; 19 | 20 | regionList: Region[]; 21 | 22 | cloudProvider: CloudProvider; 23 | name: string; 24 | cpu: number; 25 | memory: string; 26 | processor: string; 27 | }; 28 | -------------------------------------------------------------------------------- /frontend/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./dbInstance"; 2 | export * from "./searchConfig"; 3 | export * from "./region"; 4 | export * from "./term"; 5 | export * from "./common"; 6 | export * from "./table"; 7 | export * from "./route"; 8 | -------------------------------------------------------------------------------- /frontend/types/region.ts: -------------------------------------------------------------------------------- 1 | import { Term } from "./term"; 2 | 3 | export type Region = { 4 | code: string; 5 | termList: Term[]; 6 | }; 7 | 8 | export type AvailableRegion = { 9 | name: string; 10 | // providerCode is the mapping between between provider and the region code 11 | // e.g. N. Virginia is coded 'us-east-1' in AWS, us-east4 in GCP 12 | providerCode: Map; 13 | }; 14 | -------------------------------------------------------------------------------- /frontend/types/route.ts: -------------------------------------------------------------------------------- 1 | import { SearchConfigDefault } from "@/types"; 2 | 3 | export type RouteQuery = RouteQueryDashBoard | RouteQueryCompare; 4 | 5 | // RouteQueryDashBoard is the query param used in the URL for dashboard 6 | // to make the URL as simple as possible, 7 | // we do the following mapping between the attribute in SearchConfig and the attribute in Query Param: 8 | // cloudProvider -> provider 9 | // engineType -> engine 10 | // chargeType -> charge 11 | // leaseLength -> lease 12 | export type RouteQueryDashBoard = { 13 | provider?: string; 14 | engine?: string; 15 | charge?: string; 16 | region?: string; 17 | minCPU?: number; 18 | minRAM?: number; 19 | keyword?: string; 20 | lease?: number; 21 | utilization?: number; 22 | }; 23 | 24 | // RouteQueryCompare is the query param used in the URL for compare page 25 | export type RouteQueryCompare = { 26 | instance?: string; 27 | }; 28 | 29 | export const RouteQueryDashBoardDefault: RouteQueryDashBoard = { 30 | provider: SearchConfigDefault.cloudProvider?.join(","), 31 | region: SearchConfigDefault.region?.join(","), 32 | engine: SearchConfigDefault.engineType?.join(","), 33 | charge: SearchConfigDefault.chargeType?.join(","), 34 | }; 35 | 36 | export enum PageType { 37 | DASHBOARD = "dashboard", 38 | INSTANCE_DETAIL = "instanceDetail", 39 | REGION_DETAIL = "regionDetail", 40 | INSTANCE_COMPARISON = "instanceComparison", 41 | } 42 | -------------------------------------------------------------------------------- /frontend/types/searchConfig.ts: -------------------------------------------------------------------------------- 1 | import { CloudProvider, EngineType } from "./common"; 2 | import { ChargeType } from "./term"; 3 | 4 | export enum SearchBarType { 5 | DASHBOARD = "dashboard", 6 | INSTANCE_DETAIL = "instanceDetail", 7 | REGION_DETAIL = "regionDetail", 8 | INSTANCE_COMPARISON = "instanceComparison", 9 | } 10 | 11 | export type SearchConfig = { 12 | cloudProvider?: CloudProvider[]; 13 | engineType: EngineType[]; 14 | chargeType: ChargeType[]; 15 | region: string[]; 16 | minCPU: number; 17 | minRAM: number; 18 | keyword: string; 19 | utilization: number; 20 | leaseLength: number; 21 | }; 22 | 23 | export const SearchConfigEmpty: SearchConfig = { 24 | cloudProvider: [], 25 | engineType: [], 26 | chargeType: [], 27 | region: [], 28 | keyword: "", 29 | minCPU: 0, 30 | minRAM: 0, 31 | utilization: 1, 32 | leaseLength: 1, 33 | }; 34 | 35 | export const SearchConfigDefault: SearchConfig = { 36 | cloudProvider: ["AWS"], 37 | engineType: ["MYSQL"], 38 | chargeType: ["OnDemand", "Reserved"], 39 | region: ["US East (N. Virginia)"], 40 | keyword: "", 41 | minCPU: 0, 42 | minRAM: 0, 43 | utilization: 1, 44 | leaseLength: 1, 45 | }; 46 | -------------------------------------------------------------------------------- /frontend/types/table.ts: -------------------------------------------------------------------------------- 1 | import { CloudProvider, EngineType } from "./common"; 2 | 3 | export interface DataSource { 4 | id: number; 5 | key: string; 6 | 7 | cloudProvider: CloudProvider; 8 | name: string; 9 | processor: string; 10 | cpu: number; 11 | memory: string; 12 | leaseLength: string; 13 | region: string; 14 | engineType: EngineType; 15 | commitment: { usd: number }; 16 | hourly: { usd: number }; 17 | 18 | // childCnt is used as table row span length 19 | childCnt: number; 20 | // baseHourly is the on demand hourly price of the instance in the same region 21 | baseHourly: number; 22 | // expectedCost is the expected cost with lease length and utilization 23 | expectedCost: number; 24 | } 25 | 26 | export interface RelatedType { 27 | name: string; 28 | CPU: number; 29 | memory: number; 30 | hourlyUSD: number | null; 31 | } 32 | 33 | export const tablePaginationConfig = { 34 | defaultPageSize: 50, 35 | hideOnSinglePage: true, 36 | }; 37 | 38 | export interface RegionPricingType { 39 | region: string; 40 | hourlyUSD: number | null; 41 | } 42 | -------------------------------------------------------------------------------- /frontend/types/term.ts: -------------------------------------------------------------------------------- 1 | import { EngineType } from "./common"; 2 | 3 | export type ChargeType = "OnDemand" | "Reserved"; 4 | export type ContractLength = "3yr" | "1yr"; 5 | export type PurchaseOption = "All Upfront" | "Partial Upfront" | "No Upfront"; 6 | 7 | export type TermPayload = { 8 | leaseContractLength: ContractLength; 9 | purchaseOption: PurchaseOption; 10 | } | null; 11 | 12 | export type Term = { 13 | code: string; 14 | databaseEngine: EngineType; 15 | type: ChargeType; 16 | payload: TermPayload; 17 | hourlyUSD: number; 18 | commitmentUSD: number; 19 | }; 20 | 21 | export const isValidChargeType = (chargeTypeList: string[]): boolean => { 22 | for (const chargeType of chargeTypeList) { 23 | if (chargeType !== "OnDemand" && chargeType !== "Reserved") { 24 | return false; 25 | } 26 | } 27 | 28 | return true; 29 | }; 30 | -------------------------------------------------------------------------------- /frontend/utils/assets.ts: -------------------------------------------------------------------------------- 1 | export const getIconPath = (fullName: string): string => `/icons/${fullName}`; 2 | -------------------------------------------------------------------------------- /frontend/utils/compare.ts: -------------------------------------------------------------------------------- 1 | const versusStr = "-vs-"; 2 | 3 | export const slugToName = (slug: string): [string, string] => { 4 | const [comparerA, comparerB] = slug.split(versusStr); 5 | return [comparerA, comparerB]; 6 | }; 7 | 8 | export const nameToSlug = (comparerA: string, comparerB: string): string => 9 | `${comparerA}${versusStr}${comparerB}`; 10 | -------------------------------------------------------------------------------- /frontend/utils/config.ts: -------------------------------------------------------------------------------- 1 | import { ParsedUrlQuery } from "querystring"; 2 | import { isEqual } from "lodash"; 3 | import { CloudProvider, SearchConfig, EngineType, ChargeType } from "@/types"; 4 | 5 | export const hasConfigChanged = ( 6 | oldConfig: SearchConfig, 7 | newConfig: SearchConfig 8 | ): boolean => !isEqual({ ...oldConfig }, { ...newConfig }); 9 | 10 | export const shouldRefresh = ( 11 | oldConfig: SearchConfig, 12 | newConfig: SearchConfig 13 | ): boolean => 14 | !isEqual( 15 | { ...oldConfig, utilization: 0, leaseLength: 0 }, 16 | { ...newConfig, utilization: 0, leaseLength: 0 } 17 | ); 18 | 19 | // Convert the parsedUrlQuery object from next.js to a SearchConfig object 20 | export const processParsedUrlQuery = ( 21 | parsedUrlQuery: ParsedUrlQuery 22 | ): Partial => { 23 | const config: Partial = parsedUrlQuery; 24 | 25 | // These properties must be arrays. If they are not, convert them to arrays. 26 | if ( 27 | parsedUrlQuery.cloudProvider && 28 | !Array.isArray(parsedUrlQuery.cloudProvider) 29 | ) { 30 | config.cloudProvider = [parsedUrlQuery.cloudProvider as CloudProvider]; 31 | } 32 | if (parsedUrlQuery.engineType && !Array.isArray(parsedUrlQuery.engineType)) { 33 | config.engineType = [parsedUrlQuery.engineType as EngineType]; 34 | } 35 | if (parsedUrlQuery.chargeType && !Array.isArray(parsedUrlQuery.chargeType)) { 36 | config.chargeType = [parsedUrlQuery.chargeType as ChargeType]; 37 | } 38 | if (parsedUrlQuery.region && !Array.isArray(parsedUrlQuery.region)) { 39 | config.region = [parsedUrlQuery.region]; 40 | } 41 | 42 | // These properties must be numbers. 43 | if (parsedUrlQuery.minCPU) { 44 | config.minCPU = Number(parsedUrlQuery.minCPU); 45 | } 46 | if (parsedUrlQuery.minRAM) { 47 | config.minRAM = Number(parsedUrlQuery.minRAM); 48 | } 49 | if (parsedUrlQuery.utilization) { 50 | config.utilization = Number(parsedUrlQuery.utilization); 51 | } 52 | if (parsedUrlQuery.leaseLength) { 53 | config.leaseLength = Number(parsedUrlQuery.leaseLength); 54 | } 55 | return config; 56 | }; 57 | -------------------------------------------------------------------------------- /frontend/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./region"; 2 | export * from "./price"; 3 | export * from "./assets"; 4 | export * from "./config"; 5 | export * from "./table"; 6 | export * from "./instance"; 7 | export * from "./compare"; 8 | 9 | export const isEmptyArray = (arr: any[] | undefined) => { 10 | if (Array.isArray(arr) && !arr.length) { 11 | return true; 12 | } 13 | 14 | return false; 15 | }; 16 | 17 | // 10000 -> 10,000 18 | export const withComma = (value: number | string) => { 19 | return value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); 20 | }; 21 | 22 | // Convert "NaN" to "-". This is for line chart's tooltip. 23 | export const filterNaN = (value: number | string): string => { 24 | switch (typeof value) { 25 | case "number": 26 | if (Number.isNaN(value)) { 27 | return "-"; 28 | } 29 | return String(value); 30 | 31 | case "string": 32 | if (value === "NaN") { 33 | return "-"; 34 | } 35 | return value; 36 | 37 | default: 38 | return value; 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /frontend/utils/instance.ts: -------------------------------------------------------------------------------- 1 | import { CloudProvider } from "@/types"; 2 | 3 | export const getInstanceFamily = ( 4 | name: string, 5 | provider: CloudProvider, 6 | withoutSeparator: boolean = false 7 | ): string => { 8 | switch (provider) { 9 | case "AWS": 10 | if (withoutSeparator) { 11 | return `${name.split(".")[1]}`; 12 | } 13 | return `${name.split(".").slice(0, 2).join(".")}.`; 14 | case "GCP": 15 | if (withoutSeparator) { 16 | return `${name.split("-")[1]}`; 17 | } 18 | return `${name.split("-").slice(0, 2).join("-")}-`; 19 | default: 20 | return ""; 21 | } 22 | }; 23 | 24 | export const getInstanceSize = ( 25 | name: string, 26 | provider: CloudProvider 27 | ): string => { 28 | switch (provider) { 29 | case "AWS": 30 | return name.split(".")[2]; 31 | default: 32 | return ""; 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /frontend/utils/price.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from "@/types"; 2 | 3 | export const YearInHour = 365 * 24; 4 | 5 | export const getPrice = ( 6 | dataRow: DataSource, 7 | utilization: number, 8 | leaseLength: number 9 | ): number => { 10 | // charged on demand. 11 | if (dataRow.leaseLength === "On Demand") { 12 | return leaseLength * YearInHour * dataRow.hourly.usd * utilization; 13 | } 14 | 15 | // Charged reserved 16 | // Reserved means you will be charged anytime, even you do not use it, so the utilization factor is left here. 17 | let reservedCharge = leaseLength * YearInHour * dataRow.hourly.usd; 18 | // The commitment should be charged immediately. 19 | if (dataRow.leaseLength === "1yr") { 20 | reservedCharge += dataRow.commitment.usd * leaseLength; 21 | } 22 | if (dataRow.leaseLength === "3yr" && leaseLength) { 23 | reservedCharge += dataRow.commitment.usd * Math.ceil(leaseLength / 3); 24 | } 25 | return reservedCharge; 26 | }; 27 | 28 | export const getDiff = ( 29 | dataRow: DataSource, 30 | utilization: number, 31 | leaseLength: number 32 | ): number => { 33 | const baseCharge = 34 | leaseLength * YearInHour * dataRow.baseHourly * utilization; 35 | return (dataRow.expectedCost - baseCharge) / baseCharge; 36 | }; 37 | 38 | // At least N digits of the decimal part would be displayed. 39 | // If it is still 0.00, show all the digits until first 0 occurs. 40 | // e.g. 41 | // N = 2 42 | // 0.001 --> 0.00 --> 0.001 43 | // 0.011 --> 0.01 44 | // 123.011 --> 123.01 45 | export const getDigit = (val: number, N: number): string => { 46 | let res = val.toFixed(N); 47 | // If val = 0, then it should always be 0. 48 | if (Number(res) === val || N === 0) { 49 | return res; 50 | } 51 | 52 | for (let i = 0; i < N; i++) { 53 | if (res[res.length - i - 1] != "0") { 54 | return res; 55 | } 56 | } 57 | 58 | for (let i = N + 1; ; i++) { 59 | res = val.toFixed(i); 60 | if (Number(res) === val) { 61 | return res; 62 | } 63 | } 64 | }; 65 | -------------------------------------------------------------------------------- /frontend/utils/region.ts: -------------------------------------------------------------------------------- 1 | import slug from "slug"; 2 | 3 | export const regionCodeNameSlugMap: [string, string, string][] = [ 4 | // AWS 5 | ["us-gov-east-1", "AWS GovCloud (US-East)", "aws-govcloud-us-east"], 6 | ["us-gov-west-1", "AWS GovCloud (US-West)", "aws-govcloud-us-west"], 7 | ["af-south-1", "Africa (Cape Town)", "africa-cape-town"], 8 | ["ap-east-1", "Asia Pacific (Hong Kong)", "asia-pacific-hong-kong"], 9 | ["ap-northeast-1", "Asia Pacific (Tokyo)", "asia-pacific-tokyo"], 10 | ["ap-northeast-2", "Asia Pacific (Seoul)", "asia-pacific-seoul"], 11 | ["ap-northeast-3", "Asia Pacific (Osaka)", "asia-pacific-osaka"], 12 | ["ap-south-1", "Asia Pacific (Mumbai)", "asia-pacific-mumbai"], 13 | ["ap-southeast-1", "Asia Pacific (Singapore)", "asia-pacific-singapore"], 14 | ["ap-southeast-2", "Asia Pacific (Sydney)", "asia-pacific-sydney"], 15 | ["ap-southeast-3", "Asia Pacific (Jakarta)", "asia-pacific-jakarta"], 16 | ["ca-central-1", "Canada (Central)", "canada-central"], 17 | ["eu-central-1", "Europe (Frankfurt)", "europe-frankfurt"], 18 | ["eu-north-1", "Europe (Stockholm)", "europe-stockholm"], 19 | ["eu-south-1", "Europe (Milan)", "europe-milan"], 20 | ["eu-west-1", "Europe (Ireland)", "europe-ireland"], 21 | ["eu-west-2", "Europe (London)", "europe-london"], 22 | ["eu-west-3", "Europe (Paris)", "europe-paris"], 23 | ["me-south-1", "Middle East (Bahrain)", "middle-east-bahrain"], 24 | ["sa-east-1", "South America (Sao Paulo)", "south-america-sao-paulo"], 25 | ["us-east-1", "US East (N. Virginia)", "us-east-n-virginia"], 26 | ["us-east-2", "US East (Ohio)", "us-east-ohio"], 27 | ["us-west-1", "US West (N. California)", "us-west-n-california"], 28 | ["us-west-2", "US West (Oregon)", "us-west-oregon"], 29 | ["us-west-2-lax-1", "US West (Los Angeles)", "us-west-los-angeles"], 30 | ["ap-south-2", "Asia Pacific (Hyderabad)", "asia-pacific-hyderabad"], 31 | ["eu-central-2", "Europe (Zurich)", "europe-zurich"], 32 | ["eu-south-2", "Europe (Spain)", "europe-spain"], 33 | ["me-central-1", "Middle East (UAE)", "middle-east-uae"], 34 | [ 35 | "Asia Pacific (Osaka-Local)", 36 | "Asia Pacific (Osaka-Local)", 37 | "asia-pacific-osaka-local", 38 | ], 39 | 40 | // GCP 41 | ["asia-east1", "Asia Pacific (Taiwan)", "asia-pacific-taiwan"], 42 | ["asia-east2", "Asia Pacific (Hong Kong)", "asia-pacific-hong-kong"], 43 | ["asia-northeast1", "Asia Pacific (Tokyo)", "asia-pacific-tokyo"], 44 | ["asia-northeast2", "Asia Pacific (Osaka)", "asia-pacific-osaka"], 45 | ["asia-northeast3", "Asia Pacific (Seoul)", "asia-pacific-seoul"], 46 | ["asia-south1", "Asia Pacific (Mumbai)", "asia-pacific-mumbai"], 47 | ["asia-south2", "Asia Pacific (Delhi)", "asia-pacific-delhi"], 48 | ["asia-southeast1", "Asia Pacific (Singapore)", "asia-pacific-singapore"], 49 | ["asia-southeast2", "Asia Pacific (Jakarta)", "asia-pacific-jakarta"], 50 | ["australia-southeast1", "Asia Pacific (Sydney)", "asia-pacific-sydney"], 51 | [ 52 | "australia-southeast2", 53 | "Asia Pacific (Melbourne)", 54 | "asia-pacific-melbourne", 55 | ], 56 | ["europe-central2", "Europe (Warsaw)", "europe-warsaw"], 57 | ["europe-north1", "Europe (Finland)", "europe-finland"], 58 | ["europe-west1", "Europe (Belgium)", "europe-belgium"], 59 | ["europe-west2", "Europe (London)", "europe-london"], 60 | ["europe-west3", "Europe (Frankfurt)", "europe-frankfurt"], 61 | ["europe-west4", "Europe (Netherlands)", "europe-netherlands"], 62 | ["europe-west6", "Europe (Zurich)", "europe-zurich"], 63 | ["europe-west8", "Europe (Milan)", "europe-milan"], 64 | ["europe-west9", "Europe (Paris)", "europe-paris"], 65 | ["northamerica-northeast1", "Canada (Montréal)", "canada-montreal"], 66 | ["northamerica-northeast2", "Canada (Toronto)", "canada-toronto"], 67 | ["southamerica-east1", "South America (Osasco)", "south-america-osasco"], 68 | ["southamerica-west1", "South America (Santiago)", "south-america-santiago"], 69 | ["us-central1", "US Central (Iowa)", "us-central-iowa"], 70 | ["us-east1", "US East (South Carolina)", "us-east-south-carolina"], 71 | ["us-east4", "US East (N. Virginia)", "us-east-n-virginia"], 72 | ["us-west1", "US West (Oregon)", "us-west-oregon"], 73 | ["us-west2", "US West (Los Angeles)", "us-west-los-angeles"], 74 | ["us-west3", "US West (Salt Lake City)", "us-west-salt-lake-city"], 75 | ["us-west4", "US West (Las Vegas)", "us-west-las-vegas"], 76 | ["us-south1", "US South (Dallas)", "us-south-dallas"], 77 | ["me-west1", "Middle East (Tel Aviv)", "middle-east-tel-aviv"], 78 | ["us-east5", "North America (Columbus)", "north-america-columbus"], 79 | ["europe-southwest1", "Europe (Madrid)", "europe-madrid"], 80 | ["us-east7", "US East (Alabama)", "us-east-alabama"], 81 | ["us-central2", "US Central (Oklahoma)", "us-central-oklahoma"], 82 | ]; 83 | 84 | export const getRegionName = (regionCode: string): string => { 85 | if (!regionCodeNameSlugMap.some(([code]) => code === regionCode)) { 86 | // If the code is not included in the map, we just show user the code. 87 | // Also, we need to update the map manually. 88 | regionCodeNameSlugMap.push([ 89 | regionCode, 90 | `Other (${regionCode})`, 91 | slug(`Other (${regionCode})`), 92 | ]); 93 | } 94 | return regionCodeNameSlugMap.find(([code]) => code === regionCode)![1]; 95 | }; 96 | 97 | export const getRegionCode = (regionName: string): string[] => { 98 | const regionCode: string[] = []; 99 | regionCodeNameSlugMap.forEach(([code, name]) => { 100 | if (name === regionName) { 101 | regionCode.push(code); 102 | } 103 | }); 104 | 105 | return regionCode; 106 | }; 107 | 108 | // "Asia Pacific (Hong Kong)" -> "Asia Pacific" 109 | export const getRegionPrefix = (regionName: string): string => 110 | regionName.substring(0, regionName.indexOf("(")).trimEnd(); 111 | 112 | export const getRegionListByPrefix = (prefix: string): string[] => 113 | Array.from( 114 | new Set( 115 | regionCodeNameSlugMap 116 | .filter((pair) => pair[1].startsWith(prefix)) 117 | .map((pair) => pair[1]) 118 | ) 119 | ); 120 | -------------------------------------------------------------------------------- /frontend/utils/table.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from "@/types"; 2 | import { YearInHour } from "@/utils"; 3 | 4 | interface PaginationInfo { 5 | current: number; 6 | pageSize: number; 7 | } 8 | 9 | enum SorterColumn { 10 | REGION = "region", 11 | CPU = "cpu", 12 | MEMORY = "memory", 13 | EXPECTED_COST = "expectedCost", 14 | } 15 | 16 | type Comparer = { 17 | [key in SorterColumn]: ( 18 | a: DataSource, 19 | b: DataSource, 20 | isAscending: boolean 21 | ) => number; 22 | }; 23 | 24 | export const getCellRowSpan = ( 25 | dataSource: DataSource[], 26 | index: number, 27 | pagination: PaginationInfo, 28 | isFiltering: boolean 29 | ) => { 30 | // Every time pageSize changes, component state `paginationInfo.pageSize` is always a correct one. 31 | const pageSize = pagination.pageSize; 32 | // But `paginationInfo.current` is not. 33 | let current; 34 | // Our calculation of the row span is based on the pagination information. But if we're filtering in the table, 35 | // the `paginationInfo` component state will not be updated, while the actual pagination of the table is changed. 36 | // e.g. 37 | // If we're at the 2nd page of the table before search, and then we search for a keyword 38 | // which only has results less than a page size, the table will be reset to the 1st page, 39 | // but the `paginationInfo` we read from component state is still in the 2nd page. 40 | // So we need to calculate the actual pagination info if we're searching. 41 | if (isFiltering) { 42 | const pageCount = Math.ceil(dataSource.length / pageSize); 43 | if (pageCount <= pagination.current) { 44 | // If the page number of results is less than the old `paginationInfo.current`, 45 | // the actual pagination current page will be the last page. 46 | current = pageCount; 47 | } else { 48 | // Otherwise, the actual pagination current page will inherit the old `paginationInfo.current`. 49 | current = pagination.current; 50 | } 51 | } else { 52 | current = pagination.current; 53 | } 54 | 55 | // Count for rows with the same id. 56 | let sameRowCount = 1; 57 | 58 | let totalIndex = pageSize * current; 59 | totalIndex = totalIndex > dataSource.length ? dataSource.length : totalIndex; 60 | const realIndex = pageSize * (current - 1) + index; 61 | if ( 62 | index !== 0 && 63 | dataSource[realIndex - 1]?.id === dataSource[realIndex]?.id 64 | ) { 65 | sameRowCount = 0; 66 | } else { 67 | for (let i = realIndex + 1; i < totalIndex; i++) { 68 | if (dataSource[i].id === dataSource[realIndex].id) { 69 | sameRowCount++; 70 | } else { 71 | break; 72 | } 73 | } 74 | } 75 | return { rowSpan: sameRowCount }; 76 | }; 77 | 78 | // dashboardCostComparer will sort the price col by the baseline(on demand) price 79 | // and keep the baseline row at the top 80 | export const dashboardCostComparer = ( 81 | rowA: DataSource, 82 | rowB: DataSource, 83 | isAscending: boolean 84 | ): number => { 85 | if (rowA.id !== rowB.id) { 86 | return isAscending 87 | ? rowA.baseHourly * YearInHour - rowB.baseHourly * YearInHour 88 | : rowB.baseHourly * YearInHour - rowA.baseHourly * YearInHour; 89 | } 90 | if (rowA.leaseLength !== "On Demand" && rowB.leaseLength !== "On Demand") { 91 | if (rowA.expectedCost - rowB.expectedCost >= 0) { 92 | return isAscending ? 1 : -1; 93 | } else { 94 | return isAscending ? -1 : 1; 95 | } 96 | } 97 | // Make sure to put the baseline row at top. 98 | if (rowA.leaseLength === "On Demand") { 99 | return -1; 100 | } 101 | return 1; 102 | }; 103 | 104 | export const comparer: Comparer = { 105 | region: (rowA: DataSource, rowB: DataSource, isAscending: boolean) => { 106 | // sort by the case-insensitive alphabetical order 107 | const a = rowA.region.toLocaleLowerCase(); 108 | const b = rowB.region.toLocaleLowerCase(); 109 | const stringComp = a.localeCompare(b); 110 | if (stringComp !== 0) { 111 | return isAscending ? stringComp : -stringComp; 112 | } 113 | 114 | // if tow region are identical, sort them with id 115 | return isAscending ? rowA.id - rowB.id : rowB.id - rowA.id; 116 | }, 117 | cpu: (rowA: DataSource, rowB: DataSource, isAscending: boolean) => 118 | isAscending ? rowA.cpu - rowB.cpu : rowB.cpu - rowA.cpu, 119 | memory: (rowA: DataSource, rowB: DataSource, isAscending: boolean) => { 120 | return isAscending 121 | ? Number(rowA.memory) - Number(rowB.memory) 122 | : Number(rowB.memory) - Number(rowA.memory); 123 | }, 124 | expectedCost: dashboardCostComparer, 125 | }; 126 | 127 | // the order of the array will affect the order of the column of the table 128 | // the desired order is: [engine, hourly pay, commitment, lease length] 129 | export const getPricingContent = ( 130 | pricingContent: any, 131 | showLeaseLength: boolean, 132 | showEngineType: boolean 133 | ) => { 134 | const col = []; 135 | if (showLeaseLength) { 136 | col.push(pricingContent.leaseLength); 137 | } 138 | if (showEngineType) { 139 | col.push(pricingContent.commitmentWithEngine); 140 | } else { 141 | col.push(pricingContent.commitmentWithoutEngine); 142 | } 143 | col.push(pricingContent.hourlyPay); 144 | 145 | col.push(pricingContent.expectedCost); 146 | return col; 147 | }; 148 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/bytebase/dbcost 2 | 3 | go 1.19 4 | 5 | require github.com/stretchr/testify v1.9.0 6 | 7 | require ( 8 | github.com/davecgh/go-spew v1.1.1 // indirect 9 | github.com/pmezard/go-difflib v1.0.0 // indirect 10 | gopkg.in/yaml.v3 v3.0.1 // indirect 11 | ) 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 5 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 6 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 7 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 8 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 9 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 10 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 11 | -------------------------------------------------------------------------------- /seed/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "path" 7 | "sort" 8 | 9 | "github.com/bytebase/dbcost/client" 10 | "github.com/bytebase/dbcost/client/aws" 11 | "github.com/bytebase/dbcost/client/gcp" 12 | "github.com/bytebase/dbcost/store" 13 | ) 14 | 15 | const ( 16 | // renderEnvKey is set on render. 17 | renderEnvKey = "API_KEY_GCP" 18 | dirPath = "data" 19 | fileName = "dbInstance.json" 20 | ) 21 | 22 | type ProviderPair struct { 23 | Provider store.CloudProvider 24 | Client client.Client 25 | } 26 | 27 | func main() { 28 | apiKeyGCP := os.Getenv(renderEnvKey) 29 | if apiKeyGCP == "" { 30 | log.Fatalf("Env variable API_KEY_GCP not found, please set your API key in your environment first.\n") 31 | } 32 | 33 | cloudProviderList := []ProviderPair{ 34 | {store.CloudProviderGCP, gcp.NewClient(apiKeyGCP)}, 35 | {store.CloudProviderAWS, aws.NewClient()}, 36 | } 37 | 38 | incrID := 0 39 | var dbInstanceList []*store.DBInstance 40 | for _, pair := range cloudProviderList { 41 | log.Printf("--------Fetching %s--------\n", pair.Provider) 42 | offerList, err := pair.Client.GetOffer() 43 | // sort offerList to generate a stable output 44 | sort.SliceStable(offerList, func(i, j int) bool { return offerList[i].TermCode < offerList[j].TermCode }) 45 | if err != nil { 46 | log.Printf("Error occurred when fetching %s's entry.\n", pair.Provider) 47 | continue 48 | } 49 | log.Printf("Fetched %d offer entry.\n", len(offerList)) 50 | 51 | providerDBInstanceList, err := store.Convert(offerList, pair.Provider) 52 | if err != nil { 53 | log.Fatalf("Fail to covert to dbInstance.\n") 54 | } 55 | log.Printf("Converted to %d dbInstance entry.\n", len(providerDBInstanceList)) 56 | 57 | for _, instance := range providerDBInstanceList { 58 | instance.ID = incrID 59 | dbInstanceList = append(dbInstanceList, instance) 60 | incrID++ 61 | } 62 | } 63 | 64 | if err := os.MkdirAll(dirPath, os.ModePerm); err != nil { 65 | log.Fatalf("Fail to make dir, err: %s.\n", err) 66 | } 67 | 68 | log.Printf("Saving data, total entry: %d.\n", len(dbInstanceList)) 69 | 70 | if err := os.MkdirAll(dirPath, os.ModePerm); err != nil { 71 | log.Fatalf("Fail to make dir, err: err.\n") 72 | } 73 | 74 | targetFilePath := path.Join(dirPath, fileName) 75 | if err := store.Save(dbInstanceList, targetFilePath); err != nil { 76 | log.Fatalf("Fail to save data, err: %s.\n", err) 77 | } 78 | log.Printf("File saved to: %s.\n", targetFilePath) 79 | 80 | } 81 | -------------------------------------------------------------------------------- /store/common.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | // RowStatus is the type of the row status 4 | type RowStatus string 5 | 6 | const ( 7 | // RowStatusNormal is the normal status of a row 8 | RowStatusNormal RowStatus = "NORMAL" 9 | // RowStatusArchived is the archived status of a row 10 | RowStatusArchived RowStatus = "ARCHIVED" 11 | ) 12 | 13 | func (r RowStatus) String() string { 14 | switch r { 15 | case RowStatusNormal: 16 | return "NORMAL" 17 | case RowStatusArchived: 18 | return "ARCHIVED" 19 | } 20 | return "" 21 | } 22 | 23 | // CloudProvider is the type of the cloud provider, eg. GCP, Aliyun 24 | type CloudProvider string 25 | 26 | const ( 27 | // CloudProviderAWS is the enumerate type for AWS 28 | CloudProviderAWS = "AWS" 29 | // CloudProviderALIYUN is the enumerate type for ALIYUN 30 | CloudProviderALIYUN = "ALIYUN" 31 | // CloudProviderGCP is the enumerate type for GCP 32 | CloudProviderGCP = "GCP" 33 | ) 34 | 35 | func (c CloudProvider) String() string { 36 | switch c { 37 | case CloudProviderAWS: 38 | return "AWS" 39 | case CloudProviderALIYUN: 40 | return "ALIYUN" 41 | case CloudProviderGCP: 42 | return "GCP" 43 | } 44 | return "" 45 | } 46 | -------------------------------------------------------------------------------- /store/contributor.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | const SYSTEM_BOT = 0 4 | -------------------------------------------------------------------------------- /store/db_instance.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "strconv" 8 | 9 | "github.com/bytebase/dbcost/client" 10 | ) 11 | 12 | // TermPayload is the payload of the term 13 | type TermPayload struct { 14 | // e.g. 3ys 1ms 15 | LeaseContractLength string `json:"leaseContractLength"` 16 | // e.g. All Upfront, Partail Upfront 17 | PurchaseOption string `json:"purchaseOption"` 18 | } 19 | 20 | // Term is the pricing term of a given instance 21 | type Term struct { 22 | Code string `json:"code"` 23 | 24 | DatabaseEngine client.EngineType `json:"databaseEngine"` 25 | Type client.ChargeType `json:"type"` 26 | Payload *TermPayload `json:"payload"` 27 | 28 | HourlyUSD float64 `json:"hourlyUSD"` 29 | CommitmentUSD float64 `json:"commitmentUSD"` 30 | } 31 | 32 | // Region is region-price info of a given instance 33 | type Region struct { 34 | Code string `json:"code"` 35 | TermList []*Term `json:"termList"` 36 | } 37 | 38 | // DBInstance is the type of DBInstance 39 | type DBInstance struct { 40 | // system fields 41 | ID int `json:"id"` 42 | RowStatus RowStatus `json:"rowStatus"` 43 | CreatorID int `json:"creatorId"` 44 | UpdaterID int `json:"updaterId"` 45 | 46 | // Region-Price info 47 | RegionList []*Region `json:"regionList"` 48 | 49 | // domain fields 50 | CloudProvider string `json:"cloudProvider"` 51 | Name string `json:"name"` 52 | CPU int `json:"cpu"` 53 | Memory string `json:"memory"` 54 | Processor string `json:"processor"` 55 | } 56 | 57 | // Convert convert the offer provided by client to DBInstance 58 | func Convert(offerList []*client.Offer, cloudProvider CloudProvider) ([]*DBInstance, error) { 59 | termMap := make(map[int][]*Term) 60 | for _, offer := range offerList { 61 | // filter the offer does not have a instancePayload (only got price but no goods). 62 | if offer.InstancePayload == nil { 63 | continue 64 | } 65 | var termPayload *TermPayload 66 | // Only reserved type has payload field 67 | if offer.ChargeType == client.ChargeTypeReserved { 68 | termPayload = &TermPayload{ 69 | LeaseContractLength: offer.ChargePayload.LeaseContractLength, 70 | PurchaseOption: offer.ChargePayload.PurchaseOption, 71 | } 72 | } 73 | 74 | term := &Term{ 75 | Code: offer.TermCode, 76 | DatabaseEngine: offer.InstancePayload.DatabaseEngine, 77 | Type: offer.ChargeType, 78 | Payload: termPayload, 79 | HourlyUSD: offer.HourlyUSD, 80 | CommitmentUSD: offer.CommitmentUSD, 81 | } 82 | termMap[offer.ID] = append(termMap[offer.ID], term) 83 | } 84 | 85 | incrID := 0 86 | // dbInstanceMap is used to aggregate the instance by their type (e.g. db.m3.large). 87 | dbInstanceMap := make(map[string]*DBInstance) 88 | var dbInstanceList []*DBInstance 89 | // extract dbInstance from the payload field stored in the offer. 90 | for _, offer := range offerList { 91 | // filter the offer does not have a instancePayload (only got price but no goods). 92 | if offer.InstancePayload == nil { 93 | continue 94 | } 95 | 96 | instance := offer.InstancePayload 97 | cpuInt, err := strconv.Atoi(instance.CPU) 98 | if err != nil { 99 | return nil, fmt.Errorf("Fail to parse the CPU value from string to int, [val]: %v", instance.CPU) 100 | } 101 | memoryDigit := instance.Memory 102 | 103 | // we use the instance type (e.g. db.m3.xlarge) differentiate the specification of each instances, 104 | // and consider they as the same instance. 105 | if _, ok := dbInstanceMap[instance.Type]; !ok { 106 | dbInstance := &DBInstance{ 107 | ID: incrID, 108 | RowStatus: RowStatusNormal, 109 | CreatorID: SYSTEM_BOT, 110 | UpdaterID: SYSTEM_BOT, 111 | CloudProvider: cloudProvider.String(), 112 | Name: instance.Type, // e.g. db.t4g.xlarge 113 | CPU: cpuInt, 114 | Memory: memoryDigit, 115 | Processor: instance.PhysicalProcessor, 116 | } 117 | dbInstanceList = append(dbInstanceList, dbInstance) 118 | dbInstanceMap[instance.Type] = dbInstance 119 | incrID++ 120 | } 121 | 122 | // fill in the term info of the instance 123 | dbInstance := dbInstanceMap[instance.Type] 124 | for _, regionCode := range offer.RegionList { 125 | isRegionExist := false 126 | for _, region := range dbInstance.RegionList { 127 | if region.Code == regionCode { 128 | isRegionExist = true 129 | if _, ok := termMap[offer.ID]; ok { 130 | region.TermList = append(region.TermList, termMap[offer.ID]...) 131 | } 132 | } 133 | } 134 | if !isRegionExist { 135 | dbInstance.RegionList = append(dbInstance.RegionList, &Region{ 136 | Code: regionCode, 137 | TermList: termMap[offer.ID], 138 | }) 139 | } 140 | } 141 | 142 | } 143 | 144 | return dbInstanceList, nil 145 | } 146 | 147 | // Save save DBInstanceList to local .json file 148 | func Save(dbInstanceList []*DBInstance, filePath string) error { 149 | fd, err := os.Create(filePath) 150 | if err != nil { 151 | return err 152 | } 153 | 154 | dataByted, err := json.Marshal(dbInstanceList) 155 | if err != nil { 156 | return err 157 | } 158 | if _, err := fd.Write(dataByted); err != nil { 159 | return err 160 | } 161 | 162 | return fd.Close() 163 | } 164 | -------------------------------------------------------------------------------- /store/db_instance_test.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "testing" 8 | 9 | "github.com/bytebase/dbcost/client/aws" 10 | "github.com/bytebase/dbcost/client/gcp" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func Test_AWSSaveToLocal(t *testing.T) { 15 | c := aws.NewClient() 16 | offerList, err := c.GetOffer() 17 | require.NoError(t, err, "Fail to get price instance info") 18 | 19 | dbInstanceList, err := Convert(offerList, CloudProviderAWS) 20 | require.NoError(t, err, "Fail to convert to dbInstance") 21 | 22 | dirPath := fmt.Sprintf("../data/test") 23 | err = os.MkdirAll(dirPath, os.ModePerm) 24 | require.NoError(t, err, "Fail to make dir") 25 | 26 | filePath := fmt.Sprintf("%s/aws.json", dirPath) 27 | fd, err := os.Create(filePath) 28 | require.NoError(t, err, "Fail to mk file") 29 | 30 | dataByted, err := json.Marshal(dbInstanceList) 31 | require.NoError(t, err, "Fail to marshal DBInstanceList") 32 | 33 | _, err = fd.Write(dataByted) 34 | require.NoError(t, err) 35 | } 36 | 37 | func Test_GCPSaveToLocal(t *testing.T) { 38 | c := gcp.NewClient("GCP API Key") 39 | offerList, err := c.GetOffer() 40 | require.NoError(t, err, "Fail to get price instance info") 41 | 42 | dbInstanceList, err := Convert(offerList, CloudProviderGCP) 43 | require.NoError(t, err, "Fail to convert to dbInstance") 44 | 45 | dirPath := fmt.Sprintf("../data/test") 46 | err = os.MkdirAll(dirPath, os.ModePerm) 47 | require.NoError(t, err, "Fail to make dir") 48 | 49 | filePath := fmt.Sprintf("%s/gcp.json", dirPath) 50 | fd, err := os.Create(filePath) 51 | require.NoError(t, err, "Fail to mk file") 52 | 53 | dataByted, err := json.Marshal(dbInstanceList) 54 | require.NoError(t, err, "Fail to marshal DBInstanceList") 55 | 56 | _, err = fd.Write(dataByted) 57 | require.NoError(t, err) 58 | } 59 | --------------------------------------------------------------------------------