Compare commits
78 Commits
Author | SHA1 | Date |
---|---|---|
|
c855f42c00 | |
|
1e0e0f9d2c | |
|
b182f8eeed | |
|
474f285864 | |
|
aa0c95ddd2 | |
|
3dff690f8a | |
|
0d59411e83 | |
|
a6f8efa219 | |
|
7b32959631 | |
|
980c996947 | |
|
61747f15f4 | |
|
dda060aa0f | |
|
e68fb9cd2d | |
|
09fe99a22d | |
|
b8e5256ba4 | |
|
a1760dacad | |
|
0fbfae1751 | |
|
3b9b26fbf8 | |
|
6444dffdea | |
|
483d4b4d02 | |
|
244d62b05d | |
|
243c07c117 | |
|
82b4d90762 | |
|
76ef9f7ba4 | |
|
1eaf0ab553 | |
|
a81d15bf86 | |
|
7a35df1d05 | |
|
39b6cf4566 | |
|
99774230e0 | |
|
b0b885e393 | |
|
a803dcf1ec | |
|
dc9351db45 | |
|
4a7bbcabfa | |
|
93cfa29092 | |
|
84d9daf0b0 | |
|
56121e8565 | |
|
b2cb88d498 | |
|
3ab380e20b | |
|
e506066667 | |
|
812c9924c5 | |
|
9ab6d4ab7d | |
|
a00a0364ec | |
|
fca2debcf6 | |
|
870f5b172e | |
|
b5bf6fcb11 | |
|
732d1833a9 | |
|
3538dcc160 | |
|
5293871084 | |
|
2d4aa53b42 | |
|
ac06644299 | |
|
d06a251b1e | |
|
a6195994e3 | |
|
0f068665e6 | |
|
7c71e78a44 | |
|
feae30e9ef | |
|
e7c2dd8ab7 | |
|
00ae581675 | |
|
9977ce146b | |
|
2fb88d7b84 | |
|
a3aba40222 | |
|
e1496653ba | |
|
30a60f6d7f | |
|
67a03c71cc | |
|
bb7d15d91c | |
|
fda4397553 | |
|
ded5518f55 | |
|
dcbd21f018 | |
|
c787d1c208 | |
|
cb61fc4413 | |
|
ea120d7d7d | |
|
aa7fbcbc1a | |
|
c7d3146d1d | |
|
459ddc5a50 | |
|
3ce2bc2277 | |
|
ee502c1628 | |
|
83f4b6113b | |
|
8f81bc663d | |
|
6be3300e1d |
|
@ -0,0 +1,59 @@
|
||||||
|
name: 'Build Container'
|
||||||
|
description: 'Build and publish OpenCost UI container image'
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
actor:
|
||||||
|
description: 'GitHub actor'
|
||||||
|
required: true
|
||||||
|
GITHUB_TOKEN:
|
||||||
|
description: 'GitHub token for authentication'
|
||||||
|
required: true
|
||||||
|
image_tag:
|
||||||
|
description: 'Full image tag to use for the container'
|
||||||
|
required: true
|
||||||
|
release_version:
|
||||||
|
description: 'Release version for the container'
|
||||||
|
required: true
|
||||||
|
registry:
|
||||||
|
description: 'Container registry to use'
|
||||||
|
required: true
|
||||||
|
default: 'ghcr.io'
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: "composite"
|
||||||
|
steps:
|
||||||
|
- name: Log into registry
|
||||||
|
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772
|
||||||
|
with:
|
||||||
|
registry: ${{ inputs.registry }}
|
||||||
|
username: ${{ inputs.actor }}
|
||||||
|
password: ${{ inputs.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
buildkitd-flags: --debug
|
||||||
|
|
||||||
|
- name: Set up just
|
||||||
|
uses: extractions/setup-just@v3
|
||||||
|
|
||||||
|
- name: Install crane
|
||||||
|
uses: imjasonh/setup-crane@v0.3
|
||||||
|
|
||||||
|
- name: Install manifest-tool
|
||||||
|
run: |
|
||||||
|
mkdir -p manifest-tool
|
||||||
|
pushd manifest-tool
|
||||||
|
wget -q https://github.com/estesp/manifest-tool/releases/download/v2.0.8/binaries-manifest-tool-2.0.8.tar.gz
|
||||||
|
tar -xzf binaries-manifest-tool-2.0.8.tar.gz
|
||||||
|
cp manifest-tool-linux-amd64 manifest-tool
|
||||||
|
echo "$(pwd)" >> $GITHUB_PATH
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Build and push (multiarch)
|
||||||
|
env:
|
||||||
|
IMAGE_TAG: ${{ inputs.image_tag }}
|
||||||
|
RELEASE_VERSION: ${{ inputs.release_version }}
|
||||||
|
run: |
|
||||||
|
just build "$IMAGE_TAG" "$RELEASE_VERSION"
|
||||||
|
shell: bash
|
|
@ -0,0 +1,59 @@
|
||||||
|
name: Build and Publish Develop UI
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: [Build/Test]
|
||||||
|
types: [completed]
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: build-opencost-ui-develop
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Use docker.io for Docker Hub if empty
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-publish-opencost-ui:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout Repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set SHA
|
||||||
|
id: sha
|
||||||
|
run: |
|
||||||
|
echo "OC_SHORTHASH=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Set OpenCost Image Tags
|
||||||
|
id: tags
|
||||||
|
run: |
|
||||||
|
echo "IMAGE_TAG=ghcr.io/${{ github.repository_owner }}/opencost-ui:develop-${{ steps.sha.outputs.OC_SHORTHASH }}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Build and publish container
|
||||||
|
uses: ./.github/actions/build-container
|
||||||
|
with:
|
||||||
|
actor: ${{ github.actor }}
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
image_tag: ${{ steps.tags.outputs.IMAGE_TAG }}
|
||||||
|
release_version: develop-${{ steps.sha.outputs.OC_SHORTHASH }}
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
|
||||||
|
- name: Install crane
|
||||||
|
uses: imjasonh/setup-crane@v0.4
|
||||||
|
|
||||||
|
- name: Tag and push latest image
|
||||||
|
run: |
|
||||||
|
# Extract the repository part (everything before the last colon)
|
||||||
|
REPO=$(echo "${{ steps.tags.outputs.IMAGE_TAG }}" | sed 's/:.*$//')
|
||||||
|
# Create the new tag
|
||||||
|
NEW_TAG="${REPO}:develop-latest"
|
||||||
|
echo "Copying ${{ steps.tags.outputs.IMAGE_TAG }} to ${NEW_TAG}"
|
||||||
|
crane copy "${{ steps.tags.outputs.IMAGE_TAG }}" "${NEW_TAG}"
|
||||||
|
|
||||||
|
|
|
@ -59,6 +59,12 @@ jobs:
|
||||||
VERSION_NUMBER=${{ steps.version_number.outputs.RELEASE_VERSION }}
|
VERSION_NUMBER=${{ steps.version_number.outputs.RELEASE_VERSION }}
|
||||||
echo "BRANCH_NAME=v${VERSION_NUMBER%.*}" >> $GITHUB_ENV
|
echo "BRANCH_NAME=v${VERSION_NUMBER%.*}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
# checkout opencost UI to allow access to actions
|
||||||
|
- name: Checkout actions
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: 'opencost/opencost-ui'
|
||||||
|
|
||||||
- name: Checkout Repo
|
- name: Checkout Repo
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
|
@ -73,15 +79,6 @@ jobs:
|
||||||
echo "OC_SHORTHASH=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
echo "OC_SHORTHASH=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||||
popd
|
popd
|
||||||
|
|
||||||
# Login against a Docker registry except on PR
|
|
||||||
# https://github.com/docker/login-action
|
|
||||||
- name: Log into registry ${{ env.REGISTRY }}
|
|
||||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
|
|
||||||
with:
|
|
||||||
registry: ${{ env.REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set OpenCost Image Tags
|
- name: Set OpenCost Image Tags
|
||||||
id: tags
|
id: tags
|
||||||
run: |
|
run: |
|
||||||
|
@ -89,31 +86,17 @@ jobs:
|
||||||
echo "IMAGE_TAG_UI_LATEST=ghcr.io/opencost/opencost-ui:latest" >> $GITHUB_OUTPUT
|
echo "IMAGE_TAG_UI_LATEST=ghcr.io/opencost/opencost-ui:latest" >> $GITHUB_OUTPUT
|
||||||
echo "IMAGE_TAG_UI_VERSION=ghcr.io/opencost/opencost-ui:${{ steps.version_number.outputs.RELEASE_VERSION }}" >> $GITHUB_OUTPUT
|
echo "IMAGE_TAG_UI_VERSION=ghcr.io/opencost/opencost-ui:${{ steps.version_number.outputs.RELEASE_VERSION }}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Build and publish container
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: ./.github/actions/build-container
|
||||||
with:
|
with:
|
||||||
buildkitd-flags: --debug
|
actor: ${{ github.actor }}
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Set up just
|
image_tag: ${{ steps.tags.outputs.IMAGE_TAG_UI }}
|
||||||
uses: extractions/setup-just@v2
|
release_version: ${{ steps.version_number.outputs.RELEASE_VERSION }}
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
- name: Install crane
|
- name: Install crane
|
||||||
uses: imjasonh/setup-crane@v0.3
|
uses: imjasonh/setup-crane@v0.4
|
||||||
|
- name: Tag and push latest image
|
||||||
## Install manifest-tool, which is required to combine multi-arch images
|
|
||||||
## https://github.com/estesp/manifest-tool
|
|
||||||
- name: Install manifest-tool
|
|
||||||
run: |
|
run: |
|
||||||
mkdir -p manifest-tool
|
crane copy "${{ steps.tags.outputs.IMAGE_TAG_UI }}" "${{ steps.tags.outputs.IMAGE_TAG_UI_LATEST }}"
|
||||||
pushd manifest-tool
|
crane copy "${{ steps.tags.outputs.IMAGE_TAG_UI }}" "${{ steps.tags.outputs.IMAGE_TAG_UI_VERSION }}"
|
||||||
wget -q https://github.com/estesp/manifest-tool/releases/download/v2.0.8/binaries-manifest-tool-2.0.8.tar.gz
|
|
||||||
tar -xzf binaries-manifest-tool-2.0.8.tar.gz
|
|
||||||
cp manifest-tool-linux-amd64 manifest-tool
|
|
||||||
echo "$(pwd)" >> $GITHUB_PATH
|
|
||||||
|
|
||||||
- name: Build and push (multiarch) OpenCost UI
|
|
||||||
working-directory: ./opencost-ui
|
|
||||||
run: |
|
|
||||||
just build '${{ steps.tags.outputs.IMAGE_TAG_UI }}' '${{ steps.version_number.outputs.RELEASE_VERSION }}'
|
|
||||||
crane copy '${{ steps.tags.outputs.IMAGE_TAG_UI }}' '${{ steps.tags.outputs.IMAGE_TAG_UI_LATEST }}'
|
|
||||||
crane copy '${{ steps.tags.outputs.IMAGE_TAG_UI }}' '${{ steps.tags.outputs.IMAGE_TAG_UI_VERSION }}'
|
|
||||||
|
|
|
@ -0,0 +1,61 @@
|
||||||
|
name: Build and Publish UI Test Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
merge_group:
|
||||||
|
types: [checks_requested]
|
||||||
|
pull_request_target:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check_actor_permissions:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: ${{ github.event_name == 'pull_request_target' }}
|
||||||
|
outputs:
|
||||||
|
ismaintainer: ${{ steps.determine-maintainer.outputs.ismaintainer }}
|
||||||
|
steps:
|
||||||
|
- name: Check team membership
|
||||||
|
uses: tspascoal/get-user-teams-membership@v3
|
||||||
|
id: teamAffiliation
|
||||||
|
with:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.ORG_READER_PAT }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
organization: opencost
|
||||||
|
- name: determine if actor is a maintainer
|
||||||
|
id: determine-maintainer
|
||||||
|
run: |
|
||||||
|
echo "Actor: ${{ github.actor }}"
|
||||||
|
echo "Is maintainer: ${{ contains(steps.teamAffiliation.outputs.teams, 'OpenCost Maintainers') || contains(github.actor, 'dependabot[bot]') }}"
|
||||||
|
echo "ismaintainer=${{ contains(steps.teamAffiliation.outputs.teams, 'OpenCost Maintainers') || contains(github.actor, 'dependabot[bot]') }}" >> $GITHUB_OUTPUT
|
||||||
|
build-and-publish-test-image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: check_actor_permissions
|
||||||
|
if: ${{ (always() && !cancelled()) && ( github.event_name == 'merge_group' || needs.check_actor_permissions.outputs.ismaintainer == 'true') }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout Repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.merge_group.head_sha || github.event.pull_request.head.sha }}
|
||||||
|
- name: Set SHA
|
||||||
|
id: sha
|
||||||
|
run: |
|
||||||
|
echo "OC_SHORTHASH=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||||
|
- name: Set OpenCost Image Tags
|
||||||
|
id: tags
|
||||||
|
run: |
|
||||||
|
echo "IMAGE_TAG=ghcr.io/${{ github.repository_owner }}/opencost-ui:test-${{ steps.sha.outputs.OC_SHORTHASH }}" >> $GITHUB_OUTPUT
|
||||||
|
- name: Build and publish container
|
||||||
|
uses: ./.github/actions/build-container
|
||||||
|
with:
|
||||||
|
actor: ${{ github.actor }}
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
image_tag: ${{ steps.tags.outputs.IMAGE_TAG }}
|
||||||
|
release_version: test-${{ steps.sha.outputs.OC_SHORTHASH }}
|
||||||
|
registry: ${{ env.REGISTRY }}
|
|
@ -19,7 +19,7 @@ jobs:
|
||||||
|
|
||||||
-
|
-
|
||||||
name: Install just
|
name: Install just
|
||||||
uses: extractions/setup-just@v2
|
uses: extractions/setup-just@v3
|
||||||
|
|
||||||
-
|
-
|
||||||
name: Install node
|
name: Install node
|
||||||
|
|
|
@ -0,0 +1,39 @@
|
||||||
|
name: Promote UI to Demo
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: [Build and Publish Develop UI]
|
||||||
|
types: [completed]
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: build-opencost-ui-develop
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
prep-image-name:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||||
|
outputs:
|
||||||
|
image_tag: ${{ steps.tags.outputs.IMAGE_TAG }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout Repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Set SHA
|
||||||
|
id: sha
|
||||||
|
run: |
|
||||||
|
echo "OC_SHORTHASH=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Set OpenCost Image Tags
|
||||||
|
id: tags
|
||||||
|
run: |
|
||||||
|
echo "IMAGE_TAG=ghcr.io/${{ github.repository_owner }}/opencost-ui:develop-${{ steps.sha.outputs.OC_SHORTHASH }}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
install-on-demo:
|
||||||
|
needs: [prep-image-name]
|
||||||
|
uses: opencost/opencost-infra/.github/workflows/promote-to-oc-demo.yaml@main
|
||||||
|
secrets: inherit
|
||||||
|
with:
|
||||||
|
img-fqdn: ${{ needs.prep-image-name.outputs.image_tag }}
|
||||||
|
is_be: false
|
||||||
|
is_fe: true
|
|
@ -2,16 +2,16 @@
|
||||||
|
|
||||||
# OpenCost UI
|
# OpenCost UI
|
||||||
|
|
||||||
<img src="./opencost-header.png"/>
|
<img src="src/images/logo.png"/>
|
||||||
|
|
||||||
This is the web UI for the [OpenCost](http://github.com/opencost/opencost) project. You can learn more about the [User Interface](https://www.opencost.io/docs/installation/ui) in the OpenCost docs.
|
This is the web UI for the [OpenCost](http://github.com/opencost/opencost) project. You can learn more about the [User Interface](https://www.opencost.io/docs/installation/ui) in the OpenCost docs.
|
||||||
|
|
||||||
[](https://youtu.be/lCP4Ci9Kcdg)
|
[](https://youtu.be/lCP4Ci9Kcdg)
|
||||||
*OpenCost UI Walkthrough*
|
*OpenCost UI Walkthrough*
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
|
|
||||||
See https://www.opencost.io/docs/install for the full instructions.
|
See [Installation Guide](https://opencost.io/docs/installation/install) for the full instructions.
|
||||||
|
|
||||||
## Using
|
## Using
|
||||||
|
|
||||||
|
|
|
@ -18,8 +18,9 @@ else
|
||||||
sed -i "s^PLACEHOLDER_FOOTER_CONTENT^OpenCost version: $VERSION ($HEAD)^g" /var/www/*.js
|
sed -i "s^PLACEHOLDER_FOOTER_CONTENT^OpenCost version: $VERSION ($HEAD)^g" /var/www/*.js
|
||||||
fi
|
fi
|
||||||
|
|
||||||
envsubst '$API_PORT $API_SERVER $UI_PORT' < /etc/nginx/conf.d/default.nginx.conf.template > /etc/nginx/conf.d/default.nginx.conf
|
if [[ ! -e /etc/nginx/conf.d/default.nginx.conf ]];then
|
||||||
|
envsubst '$API_PORT $API_SERVER $UI_PORT' < /etc/nginx/conf.d/default.nginx.conf.template > /etc/nginx/conf.d/default.nginx.conf
|
||||||
|
fi
|
||||||
echo "Starting OpenCost UI version $VERSION ($HEAD)"
|
echo "Starting OpenCost UI version $VERSION ($HEAD)"
|
||||||
|
|
||||||
# Run the parent (nginx) container's entrypoint script
|
# Run the parent (nginx) container's entrypoint script
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
|
@ -14,14 +14,14 @@
|
||||||
"defaults"
|
"defaults"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.23.9",
|
"@babel/runtime": "^7.27.0",
|
||||||
"@date-io/core": "^1.3.13",
|
"@date-io/core": "^3.2.0",
|
||||||
"@date-io/date-fns": "^1.3.13",
|
"@date-io/date-fns": "^1.3.13",
|
||||||
"@material-ui/core": "^4.11.3",
|
"@material-ui/core": "^4.11.3",
|
||||||
"@material-ui/icons": "^4.11.2",
|
"@material-ui/icons": "^4.11.2",
|
||||||
"@material-ui/pickers": "^3.3.10",
|
"@material-ui/pickers": "^3.3.10",
|
||||||
"@material-ui/styles": "^4.11.5",
|
"@material-ui/styles": "^4.11.5",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.8.4",
|
||||||
"date-fns": "^2.30.0",
|
"date-fns": "^2.30.0",
|
||||||
"html-to-react": "^1.7.0",
|
"html-to-react": "^1.7.0",
|
||||||
"material-design-icons-iconfont": "^6.1.0",
|
"material-design-icons-iconfont": "^6.1.0",
|
||||||
|
@ -29,15 +29,15 @@
|
||||||
"react": "^17.0.1",
|
"react": "^17.0.1",
|
||||||
"react-dom": "^17.0.1",
|
"react-dom": "^17.0.1",
|
||||||
"react-router-dom": "^5.2.0",
|
"react-router-dom": "^5.2.0",
|
||||||
"recharts": "^2.2.0"
|
"recharts": "^2.15.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.13.10",
|
"@babel/core": "^7.28.0",
|
||||||
"@babel/plugin-proposal-class-properties": "^7.13.0",
|
"@babel/plugin-proposal-class-properties": "^7.13.0",
|
||||||
"@babel/plugin-transform-runtime": "^7.23.9",
|
"@babel/plugin-transform-runtime": "^7.26.10",
|
||||||
"@babel/preset-react": "^7.12.13",
|
"@babel/preset-react": "^7.26.3",
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
"parcel": "^2.11.0",
|
"parcel": "^2.14.4",
|
||||||
"process": "^0.11.10",
|
"process": "^0.11.10",
|
||||||
"set-value": "4.1.0"
|
"set-value": "4.1.0"
|
||||||
},
|
},
|
||||||
|
|
|
@ -23,10 +23,7 @@ const useStyles = makeStyles({
|
||||||
|
|
||||||
const Header = (props) => {
|
const Header = (props) => {
|
||||||
const classes = useStyles();
|
const classes = useStyles();
|
||||||
const { title, breadcrumbs } = props;
|
const { title, breadcrumbs, headerTitle } = props;
|
||||||
const { pathname } = useLocation();
|
|
||||||
|
|
||||||
const headerTitle = pathname === "/cloud" ? "Cloud Costs" : "Cost Allocation";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.root}>
|
<div className={classes.root}>
|
||||||
|
@ -36,7 +33,7 @@ const Header = (props) => {
|
||||||
<div className={classes.context}>
|
<div className={classes.context}>
|
||||||
{title && (
|
{title && (
|
||||||
<Typography variant="h4" className={classes.title}>
|
<Typography variant="h4" className={classes.title}>
|
||||||
{props.title}
|
{title}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
{breadcrumbs && breadcrumbs.length > 0 && (
|
{breadcrumbs && breadcrumbs.length > 0 && (
|
||||||
|
|
|
@ -6,6 +6,8 @@ import { BarChart } from "@material-ui/icons";
|
||||||
import { Cloud } from "@material-ui/icons";
|
import { Cloud } from "@material-ui/icons";
|
||||||
import { makeStyles } from "@material-ui/styles";
|
import { makeStyles } from "@material-ui/styles";
|
||||||
|
|
||||||
|
const logo = new URL("../../images/logo.png", import.meta.url).href;
|
||||||
|
|
||||||
const DRAWER_WIDTH = 200;
|
const DRAWER_WIDTH = 200;
|
||||||
|
|
||||||
const SidebarNav = ({ active }) => {
|
const SidebarNav = ({ active }) => {
|
||||||
|
@ -44,6 +46,7 @@ const SidebarNav = ({ active }) => {
|
||||||
icon: <BarChart />,
|
icon: <BarChart />,
|
||||||
},
|
},
|
||||||
{ name: "Cloud Costs", href: "cloud", icon: <Cloud /> },
|
{ name: "Cloud Costs", href: "cloud", icon: <Cloud /> },
|
||||||
|
{ name: "External Costs", href: "external-costs", icon: <Cloud /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -54,7 +57,7 @@ const SidebarNav = ({ active }) => {
|
||||||
variant={"permanent"}
|
variant={"permanent"}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={require("../../images/logo.png")}
|
src={logo}
|
||||||
alt="OpenCost"
|
alt="OpenCost"
|
||||||
style={{ flexShrink: 1, padding: "1rem" }}
|
style={{ flexShrink: 1, padding: "1rem" }}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -48,6 +48,7 @@ function stableSort(array, comparator) {
|
||||||
const headCells = [
|
const headCells = [
|
||||||
{ id: "name", numeric: false, label: "Name", width: "auto" },
|
{ id: "name", numeric: false, label: "Name", width: "auto" },
|
||||||
{ id: "cpuCost", numeric: true, label: "CPU", width: 90 },
|
{ id: "cpuCost", numeric: true, label: "CPU", width: 90 },
|
||||||
|
{ id: "gpuCost", numeric: true, label: "GPU", width: 90 },
|
||||||
{ id: "ramCost", numeric: true, label: "RAM", width: 90 },
|
{ id: "ramCost", numeric: true, label: "RAM", width: 90 },
|
||||||
{ id: "pvCost", numeric: true, label: "PV", width: 90 },
|
{ id: "pvCost", numeric: true, label: "PV", width: 90 },
|
||||||
{ id: "totalEfficiency", numeric: true, label: "Efficiency", width: 90 },
|
{ id: "totalEfficiency", numeric: true, label: "Efficiency", width: 90 },
|
||||||
|
@ -185,6 +186,9 @@ const AllocationReport = ({
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
{toCurrency(row.cpuCost, currency)}
|
{toCurrency(row.cpuCost, currency)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
{toCurrency(row.gpuCost, currency)}
|
||||||
|
</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
{toCurrency(row.ramCost, currency)}
|
{toCurrency(row.ramCost, currency)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
@ -209,6 +213,9 @@ const AllocationReport = ({
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
{toCurrency(row.cpuCost, currency)}
|
{toCurrency(row.cpuCost, currency)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
{toCurrency(row.gpuCost, currency)}
|
||||||
|
</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
{toCurrency(row.ramCost, currency)}
|
{toCurrency(row.ramCost, currency)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
@ -231,8 +238,8 @@ const AllocationReport = ({
|
||||||
rowsPerPage={rowsPerPage}
|
rowsPerPage={rowsPerPage}
|
||||||
rowsPerPageOptions={[10, 25, 50]}
|
rowsPerPageOptions={[10, 25, 50]}
|
||||||
page={Math.min(page, lastPage)}
|
page={Math.min(page, lastPage)}
|
||||||
onChangePage={handleChangePage}
|
onPageChange={handleChangePage}
|
||||||
onChangeRowsPerPage={handleChangeRowsPerPage}
|
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -13,7 +13,7 @@ import {
|
||||||
TableBody,
|
TableBody,
|
||||||
} from "@material-ui/core";
|
} from "@material-ui/core";
|
||||||
|
|
||||||
import { toCurrency } from "../util";
|
import { toCurrency } from "../../util";
|
||||||
import CloudCostChart from "./cloudCostChart";
|
import CloudCostChart from "./cloudCostChart";
|
||||||
import { CloudCostRow } from "./cloudCostRow";
|
import { CloudCostRow } from "./cloudCostRow";
|
||||||
|
|
||||||
|
@ -31,8 +31,6 @@ const CloudCost = ({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const classes = useStyles();
|
const classes = useStyles();
|
||||||
|
|
||||||
function descendingComparator(a, b, orderBy) {
|
function descendingComparator(a, b, orderBy) {
|
||||||
|
@ -206,8 +204,8 @@ const CloudCost = ({
|
||||||
rowsPerPage={rowsPerPage}
|
rowsPerPage={rowsPerPage}
|
||||||
rowsPerPageOptions={[10, 25, 50]}
|
rowsPerPageOptions={[10, 25, 50]}
|
||||||
page={Math.min(page, lastPage)}
|
page={Math.min(page, lastPage)}
|
||||||
onChangePage={handleChangePage}
|
onPageChange={handleChangePage}
|
||||||
onChangeRowsPerPage={handleChangeRowsPerPage}
|
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
|
@ -10,8 +10,8 @@ import {
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Cell,
|
Cell,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { primary, greyscale, browns } from "../../constants/colors";
|
import { primary, greyscale, browns } from "../../../constants/colors";
|
||||||
import { toCurrency } from "../../util";
|
import { toCurrency } from "../../../util";
|
||||||
|
|
||||||
const RangeChart = ({ data, currency, height }) => {
|
const RangeChart = ({ data, currency, height }) => {
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
|
@ -1,6 +1,6 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Modal, Paper, Typography } from "@material-ui/core";
|
import { Modal, Paper, Typography } from "@material-ui/core";
|
||||||
import Warnings from "../components/Warnings";
|
import Warnings from "../../components/Warnings";
|
||||||
import CircularProgress from "@material-ui/core/CircularProgress";
|
import CircularProgress from "@material-ui/core/CircularProgress";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -13,8 +13,8 @@ import {
|
||||||
BarChart,
|
BarChart,
|
||||||
Bar,
|
Bar,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { toCurrency } from "../util";
|
import { toCurrency } from "../../util";
|
||||||
import cloudCostDayTotals from "../services/cloudCostDayTotals";
|
import cloudCostDayTotals from "../../services/cloudCostDayTotals";
|
||||||
|
|
||||||
const CloudCostDetails = ({
|
const CloudCostDetails = ({
|
||||||
onClose,
|
onClose,
|
|
@ -2,8 +2,8 @@ import * as React from "react";
|
||||||
|
|
||||||
import { TableCell, TableRow } from "@material-ui/core";
|
import { TableCell, TableRow } from "@material-ui/core";
|
||||||
|
|
||||||
import { toCurrency } from "../util";
|
import { toCurrency } from "../../util";
|
||||||
import { primary } from "../constants/colors";
|
import { primary } from "../../constants/colors";
|
||||||
|
|
||||||
const displayCurrencyAsLessThanPenny = (amount, currency) =>
|
const displayCurrencyAsLessThanPenny = (amount, currency) =>
|
||||||
amount > 0 && amount < 0.01
|
amount > 0 && amount < 0.01
|
|
@ -6,7 +6,7 @@ import Select from "@material-ui/core/Select";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import SelectWindow from "../../components/SelectWindow";
|
import SelectWindow from "../../SelectWindow";
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
wrapper: {
|
wrapper: {
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { Modal, Paper } from "@material-ui/core";
|
||||||
|
import {
|
||||||
|
TableContainer,
|
||||||
|
TableCell,
|
||||||
|
TableRow,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
} from "@material-ui/core";
|
||||||
|
|
||||||
|
// for now, we can assume that the "Name" is resourceType
|
||||||
|
export const ExternalCostDetails = ({ row, onClose }) => (
|
||||||
|
<div>
|
||||||
|
<Modal
|
||||||
|
open={true}
|
||||||
|
onClose={onClose}
|
||||||
|
title={row.resource_type}
|
||||||
|
style={{ margin: "10%" }}
|
||||||
|
>
|
||||||
|
<Paper style={{ padding: 20 }}>
|
||||||
|
<TableContainer>
|
||||||
|
<Table>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>account_name</TableCell>
|
||||||
|
<TableCell>{row.account_name}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>aggregate</TableCell>
|
||||||
|
<TableCell>{row.aggregate}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>charge_category</TableCell>
|
||||||
|
<TableCell>{row.charge_category}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>cost</TableCell>
|
||||||
|
<TableCell>{row.cost}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>cost_source</TableCell>
|
||||||
|
<TableCell>{row.cost_source}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>cost_type</TableCell>
|
||||||
|
<TableCell>{row.cost_type}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>description</TableCell>
|
||||||
|
<TableCell>{row.description}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>domain</TableCell>
|
||||||
|
<TableCell>{row.domain}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>id</TableCell>
|
||||||
|
<TableCell>{row.id}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>list_unit_price</TableCell>
|
||||||
|
<TableCell>{row.list_unit_price}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>provider_id</TableCell>
|
||||||
|
<TableCell>{row.provider_id}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>resource_name</TableCell>
|
||||||
|
<TableCell>{row.resource_name}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>resource_type</TableCell>
|
||||||
|
<TableCell>{row.resource_type}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>usage_quantity</TableCell>
|
||||||
|
<TableCell>{row.usage_quantity}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>usage_unit</TableCell>
|
||||||
|
<TableCell>{row.usage_unit}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>zone</TableCell>
|
||||||
|
<TableCell>{row.zone}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</Paper>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
|
@ -0,0 +1,43 @@
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { TableCell, TableRow } from "@material-ui/core";
|
||||||
|
|
||||||
|
import { toCurrency } from "../../util";
|
||||||
|
import { primary } from "../../constants/colors";
|
||||||
|
|
||||||
|
const displayCurrencyAsLessThanPenny = (amount, currency) =>
|
||||||
|
amount > 0 && amount < 0.01
|
||||||
|
? `<${toCurrency(0.01, currency)}`
|
||||||
|
: toCurrency(amount, currency);
|
||||||
|
|
||||||
|
function capitalizeFirstLetter(string) {
|
||||||
|
return string.charAt(0).toUpperCase() + string.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExternalCostRow = ({
|
||||||
|
cost,
|
||||||
|
currency,
|
||||||
|
onClick,
|
||||||
|
name,
|
||||||
|
costType,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<TableRow onClick={onClick}>
|
||||||
|
<TableCell
|
||||||
|
align={"left"}
|
||||||
|
style={{ cursor: "pointer", color: "#346ef2", padding: "1rem" }}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align={"right"} style={{ paddingRight: "2em" }}>
|
||||||
|
{capitalizeFirstLetter(costType)}
|
||||||
|
</TableCell>
|
||||||
|
{/* total cost */}
|
||||||
|
<TableCell align={"right"} style={{ paddingRight: "2em" }}>
|
||||||
|
{`${displayCurrencyAsLessThanPenny(cost, currency)}`}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { ExternalCostRow };
|
|
@ -0,0 +1,31 @@
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import Typography from "@material-ui/core/Typography";
|
||||||
|
|
||||||
|
import RangeChart from "./rangeChart";
|
||||||
|
|
||||||
|
const ExternalCostsChart = ({
|
||||||
|
graphData,
|
||||||
|
currency,
|
||||||
|
n,
|
||||||
|
height,
|
||||||
|
aggregateBy,
|
||||||
|
}) => {
|
||||||
|
if (graphData.length === 0) {
|
||||||
|
return (
|
||||||
|
<Typography variant="body2" style={{ padding: "4em" }}>
|
||||||
|
No data
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<RangeChart
|
||||||
|
data={graphData}
|
||||||
|
currency={currency}
|
||||||
|
height={height}
|
||||||
|
aggregateBy={aggregateBy}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(ExternalCostsChart);
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { makeStyles } from "@material-ui/styles";
|
||||||
|
import FormControl from "@material-ui/core/FormControl";
|
||||||
|
import InputLabel from "@material-ui/core/InputLabel";
|
||||||
|
import MenuItem from "@material-ui/core/MenuItem";
|
||||||
|
import Select from "@material-ui/core/Select";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import SelectWindow from "../../components/SelectWindow";
|
||||||
|
import {
|
||||||
|
windowOptions,
|
||||||
|
aggregationOptions,
|
||||||
|
costTypeOptions
|
||||||
|
} from '../../components/externalCosts/tokens'
|
||||||
|
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
wrapper: {
|
||||||
|
display: "inline-flex",
|
||||||
|
},
|
||||||
|
formControl: {
|
||||||
|
margin: 8,
|
||||||
|
minWidth: 120,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function ExternalCostsControls({
|
||||||
|
window,
|
||||||
|
setWindow,
|
||||||
|
aggregateBy,
|
||||||
|
setAggregateBy,
|
||||||
|
costType,
|
||||||
|
setCostType
|
||||||
|
}) {
|
||||||
|
const classes = useStyles();
|
||||||
|
return (
|
||||||
|
<div className={classes.wrapper}>
|
||||||
|
<SelectWindow
|
||||||
|
windowOptions={windowOptions}
|
||||||
|
window={window}
|
||||||
|
setWindow={setWindow}
|
||||||
|
/>
|
||||||
|
<FormControl className={classes.formControl}>
|
||||||
|
<InputLabel id="aggregation-select-label">Breakdown</InputLabel>
|
||||||
|
<Select
|
||||||
|
id="aggregation-select"
|
||||||
|
value={aggregateBy}
|
||||||
|
onChange={(e) => {
|
||||||
|
setAggregateBy(e.target.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{aggregationOptions.map((opt) => (
|
||||||
|
<MenuItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl className={classes.formControl}>
|
||||||
|
<InputLabel id="aggregation-select-label">Cost Type</InputLabel>
|
||||||
|
<Select
|
||||||
|
id="cost-type-select"
|
||||||
|
value={costType}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCostType(e.target.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{costTypeOptions.map((opt) => (
|
||||||
|
<MenuItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(ExternalCostsControls);
|
|
@ -0,0 +1,182 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import { makeStyles } from "@material-ui/styles";
|
||||||
|
import {
|
||||||
|
Typography,
|
||||||
|
TableContainer,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TablePagination,
|
||||||
|
TableRow,
|
||||||
|
TableSortLabel,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
} from "@material-ui/core";
|
||||||
|
import { useLocation, useHistory } from "react-router";
|
||||||
|
import { toCurrency } from "../../util";
|
||||||
|
import { ExternalCostRow } from "./externalCostRow";
|
||||||
|
import { aggToKeyMapExternalCosts } from "./tokens";
|
||||||
|
|
||||||
|
const ExternalCostsTable = ({
|
||||||
|
tableData,
|
||||||
|
currency = "USD",
|
||||||
|
aggregateBy = "usageUnit",
|
||||||
|
drilldown,
|
||||||
|
}) => {
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
noResults: {
|
||||||
|
padding: 24,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const classes = useStyles();
|
||||||
|
|
||||||
|
const headCells = [
|
||||||
|
{
|
||||||
|
id: "aggregate",
|
||||||
|
numeric: false,
|
||||||
|
label: "Name",
|
||||||
|
width: "auto",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "costType",
|
||||||
|
numeric: false,
|
||||||
|
label: "Cost Type",
|
||||||
|
width: 160,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cost",
|
||||||
|
numeric: true,
|
||||||
|
label: "Cost",
|
||||||
|
width: 160,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const routerLocation = useLocation();
|
||||||
|
const searchParams = new URLSearchParams(routerLocation.search);
|
||||||
|
const routerHistory = useHistory();
|
||||||
|
const [page, setPage] = React.useState(0);
|
||||||
|
const [rowsPerPage, setRowsPerPage] = React.useState(25);
|
||||||
|
const numData = tableData.customCosts?.length ?? 0;
|
||||||
|
|
||||||
|
const lastPage = Math.floor(numData / rowsPerPage);
|
||||||
|
|
||||||
|
const handleChangePage = (event, newPage) => setPage(newPage);
|
||||||
|
|
||||||
|
const handleChangeRowsPerPage = (event) => {
|
||||||
|
setRowsPerPage(parseInt(event.target.value, 10));
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
let pageRows = [];
|
||||||
|
|
||||||
|
if (tableData && 'customCosts' in tableData) {
|
||||||
|
pageRows = tableData.customCosts.slice(
|
||||||
|
page * rowsPerPage,
|
||||||
|
page * rowsPerPage + rowsPerPage
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setPage(0);
|
||||||
|
}, [numData]);
|
||||||
|
|
||||||
|
if ('customCosts' in tableData && tableData.customCosts.length === 0) {
|
||||||
|
return (
|
||||||
|
<Typography variant="body2" className={classes.noResults}>
|
||||||
|
No results
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dataToExternalCostRow(row) {
|
||||||
|
return (
|
||||||
|
<ExternalCostRow
|
||||||
|
cost={row.cost}
|
||||||
|
key={row.usage_unit}
|
||||||
|
costType={row.cost_type}
|
||||||
|
onClick={() =>
|
||||||
|
// we don't want to allow drilldown on item without an empty name
|
||||||
|
row[aggToKeyMapExternalCosts[aggregateBy]] ? drilldown(row) : {}
|
||||||
|
}
|
||||||
|
name={row[aggToKeyMapExternalCosts[aggregateBy]] || "Unallocated"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const currentSortBy = searchParams.get("sortBy");
|
||||||
|
const currentSortDirection = searchParams.get("sortDirection");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="cloud-cost-table">
|
||||||
|
<TableContainer>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
{headCells.map((cell) => (
|
||||||
|
<TableCell
|
||||||
|
key={cell.id}
|
||||||
|
colSpan={cell.colspan}
|
||||||
|
align={cell.numeric ? "right" : "left"}
|
||||||
|
style={{ width: cell.width }}
|
||||||
|
>
|
||||||
|
<TableSortLabel
|
||||||
|
active={currentSortBy === cell.id}
|
||||||
|
direction={currentSortBy === cell.id ? currentSortDirection : 'desc'}
|
||||||
|
onClick={() => {
|
||||||
|
if (currentSortBy === cell.id) {
|
||||||
|
// then we simply need to update direction
|
||||||
|
searchParams.set(
|
||||||
|
"sortDirection",
|
||||||
|
currentSortDirection === "desc" ? "asc" : "desc"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
searchParams.set("sortBy", cell.id);
|
||||||
|
searchParams.set("sortDirection", "desc");
|
||||||
|
}
|
||||||
|
routerHistory.push({
|
||||||
|
search: `?${searchParams.toString()}`,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cell.label}
|
||||||
|
</TableSortLabel>
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell align={"left"} style={{ fontWeight: 500 }}>
|
||||||
|
{"Total Cost"}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell
|
||||||
|
align={"right"}
|
||||||
|
style={{ fontWeight: 500, paddingRight: "2em" }}
|
||||||
|
>
|
||||||
|
{/* Cost Type */}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
align={"right"}
|
||||||
|
style={{ fontWeight: 500, paddingRight: "2em" }}
|
||||||
|
>
|
||||||
|
{toCurrency(tableData.totalCost || 0, currency)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{pageRows.map(dataToExternalCostRow)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
<TablePagination
|
||||||
|
component="div"
|
||||||
|
count={numData}
|
||||||
|
rowsPerPage={rowsPerPage}
|
||||||
|
rowsPerPageOptions={[10, 25, 50]}
|
||||||
|
page={Math.min(page, lastPage)}
|
||||||
|
onPageChange={handleChangePage}
|
||||||
|
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(ExternalCostsTable);
|
|
@ -0,0 +1,224 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import { makeStyles } from "@material-ui/styles";
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Cell,
|
||||||
|
} from "recharts";
|
||||||
|
import { primary, greyscale, browns } from "../../constants/colors";
|
||||||
|
import { toCurrency } from "../../util";
|
||||||
|
import { aggToKeyMapExternalCosts } from "./tokens";
|
||||||
|
|
||||||
|
const RangeChart = ({ data, currency, height, aggregateBy }) => {
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
tooltip: {
|
||||||
|
borderRadius: 2,
|
||||||
|
background: "rgba(255, 255, 255, 0.95)",
|
||||||
|
padding: 12,
|
||||||
|
},
|
||||||
|
tooltipLineItem: {
|
||||||
|
fontSize: "1rem",
|
||||||
|
margin: 0,
|
||||||
|
marginBottom: 4,
|
||||||
|
padding: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const accents = [...primary, ...greyscale, ...browns];
|
||||||
|
|
||||||
|
const getItemCost = (item) => {
|
||||||
|
return item.cost;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toBar({ end, graph, start }) {
|
||||||
|
const points = graph.map((item) => ({
|
||||||
|
...item,
|
||||||
|
window: { end, start },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const dateFormatter = Intl.DateTimeFormat(navigator.language, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "numeric",
|
||||||
|
day: "numeric",
|
||||||
|
timeZone: "UTC",
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeFormatter = Intl.DateTimeFormat(navigator.language, {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "numeric",
|
||||||
|
timeZone: "UTC",
|
||||||
|
});
|
||||||
|
|
||||||
|
const s = new Date(start);
|
||||||
|
const e = new Date(end);
|
||||||
|
const interval = (e.valueOf() - s.valueOf()) / 1000 / 60 / 60;
|
||||||
|
|
||||||
|
const bar = {
|
||||||
|
end: new Date(end),
|
||||||
|
key: interval >= 24 ? dateFormatter.format(s) : timeFormatter.format(s),
|
||||||
|
items: {},
|
||||||
|
start: new Date(start),
|
||||||
|
};
|
||||||
|
|
||||||
|
points.forEach((item) => {
|
||||||
|
const windowStart = new Date(item.window.start);
|
||||||
|
const windowEnd = new Date(item.window.end);
|
||||||
|
const windowHours =
|
||||||
|
(windowEnd.valueOf() - windowStart.valueOf()) / 1000 / 60 / 60;
|
||||||
|
|
||||||
|
if (windowHours >= 24) {
|
||||||
|
bar.key = dateFormatter.format(bar.start);
|
||||||
|
} else {
|
||||||
|
bar.key = timeFormatter.format(bar.start);
|
||||||
|
}
|
||||||
|
|
||||||
|
bar.items[item[aggToKeyMapExternalCosts[aggregateBy]] || 'Unallocated'] =
|
||||||
|
getItemCost(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
return bar;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDataForDay = (dayData) => {
|
||||||
|
const { end, start } = dayData.window;
|
||||||
|
const copy = [...dayData.customCosts];
|
||||||
|
|
||||||
|
const sortedItems = copy.slice().sort((a, b) => {
|
||||||
|
return a.cost > b.cost ? -1 : 1;
|
||||||
|
});
|
||||||
|
const top8 = sortedItems.slice(0, 8);
|
||||||
|
|
||||||
|
return { end, start, graph: top8 };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDataForGraph = (dataPoints) => {
|
||||||
|
const orderedDataPoints = dataPoints.map(getDataForDay);
|
||||||
|
const bars = orderedDataPoints.map(toBar);
|
||||||
|
|
||||||
|
const keyToFill = {};
|
||||||
|
// we want to keep track of the order of fill assignment
|
||||||
|
const assignmentOrder = [];
|
||||||
|
let p = 0;
|
||||||
|
|
||||||
|
orderedDataPoints.forEach(({ graph, start, end }) => {
|
||||||
|
graph.forEach((item) => {
|
||||||
|
const key = item[aggToKeyMapExternalCosts[aggregateBy]] || 'Unallocated';
|
||||||
|
if (keyToFill[key] === undefined) {
|
||||||
|
assignmentOrder.push(key);
|
||||||
|
keyToFill[key] = accents[p];
|
||||||
|
p = (p + 1) % accents.length;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const labels = assignmentOrder.map((dataKey) => ({
|
||||||
|
dataKey,
|
||||||
|
fill: keyToFill[dataKey],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { bars, labels, keyToFill };
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
bars: barData,
|
||||||
|
labels: barLabels,
|
||||||
|
keyToFill,
|
||||||
|
} = getDataForGraph(data.timeseries);
|
||||||
|
|
||||||
|
const classes = useStyles();
|
||||||
|
|
||||||
|
const CustomTooltip = (params) => {
|
||||||
|
const { active, payload } = params;
|
||||||
|
|
||||||
|
if (!payload || payload.length == 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// come back and address issue with non-unique key names
|
||||||
|
const total = payload.reduce((sum, item) => sum + item.value, 0.0);
|
||||||
|
if (active) {
|
||||||
|
return (
|
||||||
|
<div className={classes.tooltip}>
|
||||||
|
<p
|
||||||
|
className={classes.tooltipLineItem}
|
||||||
|
style={{ color: "#000000" }}
|
||||||
|
>{`Total: ${toCurrency(total, currency)}`}</p>
|
||||||
|
|
||||||
|
{payload
|
||||||
|
.slice()
|
||||||
|
.map((item, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "20px 1fr",
|
||||||
|
gap: ".5em",
|
||||||
|
margin: ".25em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: keyToFill[item.payload.items[i][0]],
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className={classes.tooltipLineItem}>{`${
|
||||||
|
item.payload.items[i][0]
|
||||||
|
}: ${toCurrency(item.value, currency)}`}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
.reverse()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const orderedBars = barData.map((bar) => {
|
||||||
|
return {
|
||||||
|
...bar,
|
||||||
|
items: Object.entries(bar.items).sort((a, b) => (a[1] > b[1] ? -1 : 1)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer height={height} width={"100%"}>
|
||||||
|
<BarChart
|
||||||
|
data={orderedBars}
|
||||||
|
margin={{ top: 30, right: 35, left: 30, bottom: 45 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray={"3 3"} vertical={false} />
|
||||||
|
<XAxis dataKey={"key"} />
|
||||||
|
<YAxis tickFormatter={(val) => toCurrency(val, currency, 2, true)} />
|
||||||
|
<Tooltip content={<CustomTooltip />} wrapperStyle={{ zIndex: 1000 }} />
|
||||||
|
|
||||||
|
{new Array(10).fill(0).map((item, idx) => (
|
||||||
|
<Bar
|
||||||
|
dataKey={(entry) => (entry.items[idx] ? entry.items[idx][1] : null)}
|
||||||
|
stackId="x"
|
||||||
|
>
|
||||||
|
{orderedBars.map((bar) =>
|
||||||
|
bar.items[idx] ? (
|
||||||
|
<Cell fill={keyToFill[bar.items[idx][0]]} />
|
||||||
|
) : (
|
||||||
|
<Cell />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Bar>
|
||||||
|
))}
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RangeChart;
|
|
@ -0,0 +1,39 @@
|
||||||
|
const costTypeOptions = [
|
||||||
|
{ name: "Blended", value: "blended" },
|
||||||
|
{ name: "Billed", value: "billed" },
|
||||||
|
{ name: "List", value: "list" },
|
||||||
|
];
|
||||||
|
const windowOptions = [
|
||||||
|
{ name: "Today", value: "today" },
|
||||||
|
{ name: "Yesterday", value: "yesterday" },
|
||||||
|
{ name: "Last 24h", value: "24h" },
|
||||||
|
{ name: "Last 48h", value: "48h" },
|
||||||
|
{ name: "Week-to-date", value: "week" },
|
||||||
|
{ name: "Last week", value: "lastweek" },
|
||||||
|
{ name: "Last 7 days", value: "7d" },
|
||||||
|
{ name: "Last 14 days", value: "14d" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const aggregationOptions = [
|
||||||
|
{ name: "Domain", value: "domain" },
|
||||||
|
{ name: "Account Name", value: "accountName" },
|
||||||
|
{ name: "Resource Name", value: "resourceName" },
|
||||||
|
{ name: "Resource Type", value: "resourceType" },
|
||||||
|
{ name: "Zone", value: "zone" },
|
||||||
|
{ name: "Charge Category", value: "chargeCategory" },
|
||||||
|
{ name: "Provider ID", value: "providerId" },
|
||||||
|
{ name: "Usage Unit", value: "usageUnit" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const aggToKeyMapExternalCosts = {
|
||||||
|
zone: "zone",
|
||||||
|
accountName: "account_name",
|
||||||
|
chargeCategory: "charge_category",
|
||||||
|
resourceName: "resource_name",
|
||||||
|
resourceType: "resource_type",
|
||||||
|
providerId: "provider_id",
|
||||||
|
usageUnit: "usage_unit",
|
||||||
|
domain: "domain",
|
||||||
|
};
|
||||||
|
|
||||||
|
export { costTypeOptions, windowOptions, aggregationOptions, aggToKeyMapExternalCosts };
|
|
@ -18,21 +18,21 @@ import React, { useEffect, useState } from "react";
|
||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
import { useLocation, useHistory } from "react-router";
|
import { useLocation, useHistory } from "react-router";
|
||||||
|
|
||||||
import AllocationReport from "./components/allocationReport";
|
import AllocationReport from "../components/allocationReport";
|
||||||
import Controls from "./components/Controls";
|
import Controls from "../components/Controls";
|
||||||
import Header from "./components/Header";
|
import Header from "../components/Header";
|
||||||
import Page from "./components/Page";
|
import Page from "../components/Page";
|
||||||
import Footer from "./components/Footer";
|
import Footer from "../components/Footer";
|
||||||
import Subtitle from "./components/Subtitle";
|
import Subtitle from "../components/Subtitle";
|
||||||
import Warnings from "./components/Warnings";
|
import Warnings from "../components/Warnings";
|
||||||
import AllocationService from "./services/allocation";
|
import AllocationService from "../services/allocation";
|
||||||
import {
|
import {
|
||||||
checkCustomWindow,
|
checkCustomWindow,
|
||||||
cumulativeToTotals,
|
cumulativeToTotals,
|
||||||
rangeToCumulative,
|
rangeToCumulative,
|
||||||
toVerboseTimeRange,
|
toVerboseTimeRange,
|
||||||
} from "./util";
|
} from "../util";
|
||||||
import { currencyCodes } from "./constants/currencyCodes";
|
import { currencyCodes } from "../constants/currencyCodes";
|
||||||
|
|
||||||
const windowOptions = [
|
const windowOptions = [
|
||||||
{ name: "Today", value: "today" },
|
{ name: "Today", value: "today" },
|
||||||
|
@ -231,7 +231,7 @@ const ReportsPage = () => {
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Page active="reports.html">
|
<Page active="reports.html">
|
||||||
<Header>
|
<Header headerTitle='Cost Allocation'>
|
||||||
<IconButton aria-label="refresh" onClick={() => setFetch(true)}>
|
<IconButton aria-label="refresh" onClick={() => setFetch(true)}>
|
||||||
<RefreshIcon />
|
<RefreshIcon />
|
||||||
</IconButton>
|
</IconButton>
|
|
@ -1,7 +1,7 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import Page from "./components/Page";
|
import Page from "../components/Page";
|
||||||
import Header from "./components/Header";
|
import Header from "../components/Header";
|
||||||
import Footer from "./components/Footer";
|
import Footer from "../components/Footer";
|
||||||
import IconButton from "@material-ui/core/IconButton";
|
import IconButton from "@material-ui/core/IconButton";
|
||||||
import RefreshIcon from "@material-ui/icons/Refresh";
|
import RefreshIcon from "@material-ui/icons/Refresh";
|
||||||
import { makeStyles } from "@material-ui/styles";
|
import { makeStyles } from "@material-ui/styles";
|
||||||
|
@ -10,23 +10,24 @@ import CircularProgress from "@material-ui/core/CircularProgress";
|
||||||
import { get, find } from "lodash";
|
import { get, find } from "lodash";
|
||||||
import { useLocation, useHistory } from "react-router";
|
import { useLocation, useHistory } from "react-router";
|
||||||
|
|
||||||
import { checkCustomWindow, toVerboseTimeRange } from "./util";
|
import { checkCustomWindow, toVerboseTimeRange } from "../util";
|
||||||
import CloudCostEditControls from "./cloudCost/controls/cloudCostEditControls";
|
import CloudCostEditControls from "../components/cloudCost/controls/cloudCostEditControls";
|
||||||
import Subtitle from "./components/Subtitle";
|
import Subtitle from "../components/Subtitle";
|
||||||
import Warnings from "./components/Warnings";
|
import Warnings from "../components/Warnings";
|
||||||
import CloudCostTopService from "./services/cloudCostTop";
|
import CloudCostTopService from "../services/cloudCostTop";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
windowOptions,
|
windowOptions,
|
||||||
costMetricOptions,
|
costMetricOptions,
|
||||||
aggregationOptions,
|
aggregationOptions,
|
||||||
aggMap,
|
aggMap,
|
||||||
} from "./cloudCost/tokens";
|
} from "../components/cloudCost/tokens";
|
||||||
import { currencyCodes } from "./constants/currencyCodes";
|
|
||||||
import CloudCost from "./cloudCost/cloudCost";
|
|
||||||
import { CloudCostDetails } from "./cloudCost/cloudCostDetails";
|
|
||||||
|
|
||||||
const CloudCostReports = () => {
|
import { currencyCodes } from "../constants/currencyCodes";
|
||||||
|
import CloudCost from "../components/cloudCost/cloudCost";
|
||||||
|
import { CloudCostDetails } from "../components/cloudCost/cloudCostDetails";
|
||||||
|
|
||||||
|
const CloudCosts = () => {
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
reportHeader: {
|
reportHeader: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
@ -230,7 +231,7 @@ const CloudCostReports = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page active="cloud.html">
|
<Page active="cloud.html">
|
||||||
<Header>
|
<Header headerTitle='Cloud Costs'>
|
||||||
<IconButton aria-label="refresh" onClick={() => setFetch(true)}>
|
<IconButton aria-label="refresh" onClick={() => setFetch(true)}>
|
||||||
<RefreshIcon />
|
<RefreshIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
@ -334,4 +335,4 @@ const CloudCostReports = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default React.memo(CloudCostReports);
|
export default React.memo(CloudCosts);
|
|
@ -0,0 +1,325 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import Page from "../components/Page";
|
||||||
|
import Header from "../components/Header";
|
||||||
|
import Footer from "../components/Footer";
|
||||||
|
import IconButton from "@material-ui/core/IconButton";
|
||||||
|
import RefreshIcon from "@material-ui/icons/Refresh";
|
||||||
|
import { makeStyles } from "@material-ui/styles";
|
||||||
|
import { Paper, Button } from "@material-ui/core";
|
||||||
|
import CircularProgress from "@material-ui/core/CircularProgress";
|
||||||
|
|
||||||
|
import { useLocation, useHistory } from "react-router";
|
||||||
|
import Warnings from "../components/Warnings";
|
||||||
|
import ExternalCostsService from "../services/externalCosts";
|
||||||
|
|
||||||
|
import {
|
||||||
|
windowOptions,
|
||||||
|
aggregationOptions,
|
||||||
|
costTypeOptions,
|
||||||
|
} from "../components/externalCosts/tokens";
|
||||||
|
import ExternalCostsControls from "../components/externalCosts/externalCostsControls";
|
||||||
|
import ExternalCostsChart from "../components/externalCosts/externalCostsChart";
|
||||||
|
import ExternalCostsTable from "../components/externalCosts/externalCostsTable";
|
||||||
|
import { aggToKeyMapExternalCosts } from "../components/externalCosts/tokens";
|
||||||
|
import { ExternalCostDetails } from "../components/externalCosts/externalCostDetailModal";
|
||||||
|
|
||||||
|
const ExternalCosts = () => {
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
reportHeader: {
|
||||||
|
display: "flex",
|
||||||
|
flexFlow: "row",
|
||||||
|
padding: 24,
|
||||||
|
},
|
||||||
|
titles: {
|
||||||
|
flexGrow: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const classes = useStyles();
|
||||||
|
const [window, setWindow] = React.useState(windowOptions[0].value);
|
||||||
|
const [costType, setCostType] = React.useState(costTypeOptions[0].value);
|
||||||
|
const [sortBy, setSortBy] = React.useState("cost");
|
||||||
|
const [sortDirection, setSortDirection] = React.useState("desc");
|
||||||
|
const [aggregateBy, setAggregateBy] = React.useState(
|
||||||
|
aggregationOptions[0].value
|
||||||
|
);
|
||||||
|
const [filters, setFilters] = React.useState([]);
|
||||||
|
const [currency, setCurrency] = React.useState("USD");
|
||||||
|
|
||||||
|
// page and settings state
|
||||||
|
const [init, setInit] = React.useState(false);
|
||||||
|
const [fetch, setFetch] = React.useState(false);
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [errors, setErrors] = React.useState([]);
|
||||||
|
const [showModal, setShowModal] = React.useState(false);
|
||||||
|
|
||||||
|
// data
|
||||||
|
const [externalCostData, setExternalCostData] = React.useState([]);
|
||||||
|
const [externalCostTableData, setExternalCostTableData] = React.useState([]);
|
||||||
|
|
||||||
|
// parse any context information from the URL
|
||||||
|
const routerLocation = useLocation();
|
||||||
|
const searchParams = new URLSearchParams(routerLocation.search);
|
||||||
|
const routerHistory = useHistory();
|
||||||
|
|
||||||
|
async function initialize() {
|
||||||
|
setInit(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchChartData() {
|
||||||
|
try {
|
||||||
|
const resp = await ExternalCostsService.fetchExternalGraphCosts(
|
||||||
|
window,
|
||||||
|
aggregateBy,
|
||||||
|
filters,
|
||||||
|
costType,
|
||||||
|
sortBy,
|
||||||
|
sortDirection
|
||||||
|
);
|
||||||
|
if (resp) {
|
||||||
|
setExternalCostData(resp);
|
||||||
|
} else {
|
||||||
|
if (resp.message && resp.message.indexOf("boundary error") >= 0) {
|
||||||
|
let match = resp.message.match(/(ETL is \d+\.\d+% complete)/);
|
||||||
|
let secondary = "Try again after ETL build is complete";
|
||||||
|
if (match.length > 0) {
|
||||||
|
secondary = `${match[1]}. ${secondary}`;
|
||||||
|
}
|
||||||
|
setErrors([
|
||||||
|
{
|
||||||
|
primary: "Data unavailable while ETL is building",
|
||||||
|
secondary: secondary,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
setExternalCostData([]);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
if (err.message.indexOf("404") === 0) {
|
||||||
|
setErrors([
|
||||||
|
{
|
||||||
|
primary: "Failed to load report data",
|
||||||
|
secondary:
|
||||||
|
"Please update OpenCost to the latest version, and open an Issue if problems persist.",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
let secondary =
|
||||||
|
"Please open an Issue with OpenCost if problems persist.";
|
||||||
|
if (err.message.length > 0) {
|
||||||
|
secondary = err.message;
|
||||||
|
}
|
||||||
|
setErrors([
|
||||||
|
{
|
||||||
|
primary: "Failed to load report data",
|
||||||
|
secondary: secondary,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
setExternalCostData([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function fetchTableData() {
|
||||||
|
try {
|
||||||
|
const resp = await ExternalCostsService.fetchExternalTableCosts(
|
||||||
|
window,
|
||||||
|
aggregateBy,
|
||||||
|
filters,
|
||||||
|
costType,
|
||||||
|
sortBy,
|
||||||
|
sortDirection
|
||||||
|
);
|
||||||
|
if (resp) {
|
||||||
|
setExternalCostTableData(resp);
|
||||||
|
} else {
|
||||||
|
if (resp.message && resp.message.indexOf("boundary error") >= 0) {
|
||||||
|
let match = resp.message.match(/(ETL is \d+\.\d+% complete)/);
|
||||||
|
let secondary = "Try again after ETL build is complete";
|
||||||
|
if (match.length > 0) {
|
||||||
|
secondary = `${match[1]}. ${secondary}`;
|
||||||
|
}
|
||||||
|
setErrors([
|
||||||
|
{
|
||||||
|
primary: "Data unavailable while ETL is building",
|
||||||
|
secondary: secondary,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
setExternalCostTableData([]);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
if (err.message.indexOf("404") === 0) {
|
||||||
|
setErrors([
|
||||||
|
{
|
||||||
|
primary: "Failed to load report data",
|
||||||
|
secondary:
|
||||||
|
"Please update OpenCost to the latest version, and open an Issue if problems persist.",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
let secondary =
|
||||||
|
"Please open an Issue with OpenCost if problems persist.";
|
||||||
|
if (err.message.length > 0) {
|
||||||
|
secondary = err.message;
|
||||||
|
}
|
||||||
|
setErrors([
|
||||||
|
{
|
||||||
|
primary: "Failed to load report data",
|
||||||
|
secondary: secondary,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
setExternalCostTableData([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchData() {
|
||||||
|
setLoading(true);
|
||||||
|
setErrors([]);
|
||||||
|
// todo: come back and have inidividual loading
|
||||||
|
await fetchChartData();
|
||||||
|
await fetchTableData();
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drilldown(row) {
|
||||||
|
// Drilldown order: domain > account Name > resource type > resource name
|
||||||
|
// We only want drilldown functionality on these items
|
||||||
|
if (
|
||||||
|
["domain", "accountName", "resourceType", "resourceName"].includes(
|
||||||
|
aggregateBy
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
setFilters([
|
||||||
|
...filters,
|
||||||
|
{
|
||||||
|
property: aggregateBy,
|
||||||
|
value: row[aggToKeyMapExternalCosts[aggregateBy]],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
if (aggregateBy === "domain") {
|
||||||
|
setAggregateBy("accountName");
|
||||||
|
} else if (aggregateBy === "accountName") {
|
||||||
|
setAggregateBy("resourceType");
|
||||||
|
} else if (aggregateBy === "resourceType") {
|
||||||
|
setAggregateBy("resourceName");
|
||||||
|
} else {
|
||||||
|
setShowModal(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setWindow(searchParams.get("window") || "7d");
|
||||||
|
setAggregateBy(searchParams.get("agg") || "domain");
|
||||||
|
setCurrency(searchParams.get("currency") || "USD");
|
||||||
|
setCostType(searchParams.get("costType") || "blended");
|
||||||
|
setSortBy(searchParams.get("sortBy") || "cost");
|
||||||
|
setSortDirection(searchParams.get("sortDirection") || "desc");
|
||||||
|
}, [routerLocation]);
|
||||||
|
|
||||||
|
// Initialize once, then fetch report each time setFetch(true) is called
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!init) {
|
||||||
|
initialize();
|
||||||
|
}
|
||||||
|
if (init || fetch) {
|
||||||
|
fetchData();
|
||||||
|
}
|
||||||
|
}, [init, fetch]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setFetch(!fetch);
|
||||||
|
}, [window, aggregateBy, filters, costType, sortBy, sortDirection]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page active="cloud.html">
|
||||||
|
{/* figure out if we need */}
|
||||||
|
<Header headerTitle="External Costs">
|
||||||
|
<IconButton aria-label="refresh" onClick={() => setFetch(true)}>
|
||||||
|
<RefreshIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Header>
|
||||||
|
{!loading && errors.length > 0 && (
|
||||||
|
<div style={{ marginBottom: 20 }}>
|
||||||
|
<Warnings warnings={errors} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{init && (
|
||||||
|
<Paper id="cloud-cost">
|
||||||
|
<div className={classes.reportHeader}>
|
||||||
|
<ExternalCostsControls
|
||||||
|
costType={costType}
|
||||||
|
setCostType={(type) => {
|
||||||
|
searchParams.set("costType", type);
|
||||||
|
routerHistory.push({
|
||||||
|
search: `?${searchParams.toString()}`,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
window={window}
|
||||||
|
setWindow={(win) => {
|
||||||
|
searchParams.set("window", win);
|
||||||
|
routerHistory.push({
|
||||||
|
search: `?${searchParams.toString()}`,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
aggregateBy={aggregateBy}
|
||||||
|
setAggregateBy={(agg) => {
|
||||||
|
setFilters([]);
|
||||||
|
searchParams.set("agg", agg);
|
||||||
|
routerHistory.push({
|
||||||
|
search: `?${searchParams.toString()}`,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div style={{ display: "flex", justifyContent: "center" }}>
|
||||||
|
<div style={{ paddingTop: 100, paddingBottom: 100 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && (
|
||||||
|
<>
|
||||||
|
<ExternalCostsChart
|
||||||
|
graphData={externalCostData}
|
||||||
|
currency={"USD"}
|
||||||
|
height={300}
|
||||||
|
aggregateBy={aggregateBy}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
style={{ margin: ".5em" }}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => setFilters([])}
|
||||||
|
>
|
||||||
|
Clear Filters
|
||||||
|
</Button>
|
||||||
|
{!loading && (
|
||||||
|
<>
|
||||||
|
<ExternalCostsTable
|
||||||
|
tableData={externalCostTableData}
|
||||||
|
aggregateBy={aggregateBy}
|
||||||
|
drilldown={drilldown}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{showModal && (
|
||||||
|
<ExternalCostDetails
|
||||||
|
row={showModal}
|
||||||
|
onClose={() => setShowModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
<Footer />
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(ExternalCosts);
|
10
src/route.js
10
src/route.js
|
@ -1,8 +1,9 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { BrowserRouter as Router, Route, Switch } from "react-router-dom";
|
import { BrowserRouter as Router, Route, Switch } from "react-router-dom";
|
||||||
|
|
||||||
import Reports from "./Reports.js";
|
import Reports from "./pages/Allocations.js";
|
||||||
import CloudCostReports from "./cloudCostReports.js";
|
import CloudCosts from "./pages/CloudCosts.js";
|
||||||
|
import ExternalCosts from "./pages/ExternalCosts.js";
|
||||||
|
|
||||||
const Routes = () => {
|
const Routes = () => {
|
||||||
return (
|
return (
|
||||||
|
@ -15,7 +16,10 @@ const Routes = () => {
|
||||||
<Reports />
|
<Reports />
|
||||||
</Route>
|
</Route>
|
||||||
<Route exact path="/cloud">
|
<Route exact path="/cloud">
|
||||||
<CloudCostReports />
|
<CloudCosts />
|
||||||
|
</Route>
|
||||||
|
<Route exact path="/external-costs">
|
||||||
|
<ExternalCosts />
|
||||||
</Route>
|
</Route>
|
||||||
</Switch>
|
</Switch>
|
||||||
</Router>
|
</Router>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { parseFilters } from "../util";
|
import { parseFilters } from "../util";
|
||||||
import { costMetricToPropName } from "../cloudCost/tokens";
|
import { costMetricToPropName } from "../components/cloudCost/tokens";
|
||||||
|
|
||||||
function formatItemsForCost({ data, costType }) {
|
function formatItemsForCost({ data, costType }) {
|
||||||
return data.sets.map(({ cloudCosts, window }) => {
|
return data.sets.map(({ cloudCosts, window }) => {
|
||||||
|
|
|
@ -0,0 +1,60 @@
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
// API blows up when comma is encoded
|
||||||
|
export function parseExternalCostFilters(filters) {
|
||||||
|
if (typeof filters === "string") {
|
||||||
|
return filters;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
[...new Set(filters.map((f) => `${f.property}:"${f.value}"`))].join(
|
||||||
|
'+'
|
||||||
|
) || ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExternalCostsService {
|
||||||
|
BASE_URL = process.env.BASE_URL || "{PLACEHOLDER_BASE_URL}";
|
||||||
|
|
||||||
|
async fetchExternalGraphCosts(win, aggregate, filters, costType, sortBy, sortDirection) {
|
||||||
|
if (this.BASE_URL.includes("PLACEHOLDER_BASE_URL")) {
|
||||||
|
this.BASE_URL = `http://localhost:9090/model`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
window: win,
|
||||||
|
aggregate: aggregate,
|
||||||
|
costType,
|
||||||
|
filter: parseExternalCostFilters(filters),
|
||||||
|
sortBy,
|
||||||
|
sortDirection
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await axios.get(`${this.BASE_URL}/customCost/timeseries`, {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return result.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchExternalTableCosts(win, aggregate, filters, costType, sortBy, sortDirection) {
|
||||||
|
if (this.BASE_URL.includes("PLACEHOLDER_BASE_URL")) {
|
||||||
|
this.BASE_URL = `http://localhost:9090/model`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
window: win,
|
||||||
|
aggregate: aggregate,
|
||||||
|
costType,
|
||||||
|
filter: parseExternalCostFilters(filters),
|
||||||
|
sortBy,
|
||||||
|
sortDirection
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await axios.get(`${this.BASE_URL}/customCost/total`, {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return result.data.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new ExternalCostsService();
|
|
@ -1,5 +1,5 @@
|
||||||
import { forEach, get, round } from "lodash";
|
import { forEach, get, round } from "lodash";
|
||||||
import { costMetricToPropName } from "./cloudCost/tokens";
|
import { costMetricToPropName } from "./components/cloudCost/tokens";
|
||||||
|
|
||||||
// rangeToCumulative takes an AllocationSetRange (type: array[AllocationSet])
|
// rangeToCumulative takes an AllocationSetRange (type: array[AllocationSet])
|
||||||
// and accumulates the values into a single AllocationSet (type: object)
|
// and accumulates the values into a single AllocationSet (type: object)
|
||||||
|
|
Loading…
Reference in New Issue