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 }}
|
||||
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
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
|
@ -73,15 +79,6 @@ jobs:
|
|||
echo "OC_SHORTHASH=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
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
|
||||
id: tags
|
||||
run: |
|
||||
|
@ -89,31 +86,17 @@ jobs:
|
|||
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
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Build and publish container
|
||||
uses: ./.github/actions/build-container
|
||||
with:
|
||||
buildkitd-flags: --debug
|
||||
|
||||
- name: Set up just
|
||||
uses: extractions/setup-just@v2
|
||||
|
||||
actor: ${{ github.actor }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
image_tag: ${{ steps.tags.outputs.IMAGE_TAG_UI }}
|
||||
release_version: ${{ steps.version_number.outputs.RELEASE_VERSION }}
|
||||
registry: ${{ env.REGISTRY }}
|
||||
- name: Install crane
|
||||
uses: imjasonh/setup-crane@v0.3
|
||||
|
||||
## Install manifest-tool, which is required to combine multi-arch images
|
||||
## https://github.com/estesp/manifest-tool
|
||||
- name: Install manifest-tool
|
||||
uses: imjasonh/setup-crane@v0.4
|
||||
- name: Tag and push latest image
|
||||
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
|
||||
|
||||
- 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 }}'
|
||||
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
|
||||
uses: extractions/setup-just@v2
|
||||
uses: extractions/setup-just@v3
|
||||
|
||||
-
|
||||
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
|
||||
|
||||
<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.
|
||||
|
||||
[](https://youtu.be/lCP4Ci9Kcdg)
|
||||
[](https://youtu.be/lCP4Ci9Kcdg)
|
||||
*OpenCost UI Walkthrough*
|
||||
|
||||
## 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
|
||||
|
||||
|
|
|
@ -18,8 +18,9 @@ else
|
|||
sed -i "s^PLACEHOLDER_FOOTER_CONTENT^OpenCost version: $VERSION ($HEAD)^g" /var/www/*.js
|
||||
fi
|
||||
|
||||
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)"
|
||||
|
||||
# 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"
|
||||
],
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.23.9",
|
||||
"@date-io/core": "^1.3.13",
|
||||
"@babel/runtime": "^7.27.0",
|
||||
"@date-io/core": "^3.2.0",
|
||||
"@date-io/date-fns": "^1.3.13",
|
||||
"@material-ui/core": "^4.11.3",
|
||||
"@material-ui/icons": "^4.11.2",
|
||||
"@material-ui/pickers": "^3.3.10",
|
||||
"@material-ui/styles": "^4.11.5",
|
||||
"axios": "^1.6.0",
|
||||
"axios": "^1.8.4",
|
||||
"date-fns": "^2.30.0",
|
||||
"html-to-react": "^1.7.0",
|
||||
"material-design-icons-iconfont": "^6.1.0",
|
||||
|
@ -29,15 +29,15 @@
|
|||
"react": "^17.0.1",
|
||||
"react-dom": "^17.0.1",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"recharts": "^2.2.0"
|
||||
"recharts": "^2.15.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.13.10",
|
||||
"@babel/core": "^7.28.0",
|
||||
"@babel/plugin-proposal-class-properties": "^7.13.0",
|
||||
"@babel/plugin-transform-runtime": "^7.23.9",
|
||||
"@babel/preset-react": "^7.12.13",
|
||||
"@babel/plugin-transform-runtime": "^7.26.10",
|
||||
"@babel/preset-react": "^7.26.3",
|
||||
"buffer": "^6.0.3",
|
||||
"parcel": "^2.11.0",
|
||||
"parcel": "^2.14.4",
|
||||
"process": "^0.11.10",
|
||||
"set-value": "4.1.0"
|
||||
},
|
||||
|
|
|
@ -23,10 +23,7 @@ const useStyles = makeStyles({
|
|||
|
||||
const Header = (props) => {
|
||||
const classes = useStyles();
|
||||
const { title, breadcrumbs } = props;
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const headerTitle = pathname === "/cloud" ? "Cloud Costs" : "Cost Allocation";
|
||||
const { title, breadcrumbs, headerTitle } = props;
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
|
@ -36,7 +33,7 @@ const Header = (props) => {
|
|||
<div className={classes.context}>
|
||||
{title && (
|
||||
<Typography variant="h4" className={classes.title}>
|
||||
{props.title}
|
||||
{title}
|
||||
</Typography>
|
||||
)}
|
||||
{breadcrumbs && breadcrumbs.length > 0 && (
|
||||
|
|
|
@ -6,6 +6,8 @@ import { BarChart } from "@material-ui/icons";
|
|||
import { Cloud } from "@material-ui/icons";
|
||||
import { makeStyles } from "@material-ui/styles";
|
||||
|
||||
const logo = new URL("../../images/logo.png", import.meta.url).href;
|
||||
|
||||
const DRAWER_WIDTH = 200;
|
||||
|
||||
const SidebarNav = ({ active }) => {
|
||||
|
@ -44,6 +46,7 @@ const SidebarNav = ({ active }) => {
|
|||
icon: <BarChart />,
|
||||
},
|
||||
{ name: "Cloud Costs", href: "cloud", icon: <Cloud /> },
|
||||
{ name: "External Costs", href: "external-costs", icon: <Cloud /> },
|
||||
];
|
||||
|
||||
return (
|
||||
|
@ -54,7 +57,7 @@ const SidebarNav = ({ active }) => {
|
|||
variant={"permanent"}
|
||||
>
|
||||
<img
|
||||
src={require("../../images/logo.png")}
|
||||
src={logo}
|
||||
alt="OpenCost"
|
||||
style={{ flexShrink: 1, padding: "1rem" }}
|
||||
/>
|
||||
|
|
|
@ -48,6 +48,7 @@ function stableSort(array, comparator) {
|
|||
const headCells = [
|
||||
{ id: "name", numeric: false, label: "Name", width: "auto" },
|
||||
{ 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: "pvCost", numeric: true, label: "PV", width: 90 },
|
||||
{ id: "totalEfficiency", numeric: true, label: "Efficiency", width: 90 },
|
||||
|
@ -185,6 +186,9 @@ const AllocationReport = ({
|
|||
<TableCell align="right">
|
||||
{toCurrency(row.cpuCost, currency)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{toCurrency(row.gpuCost, currency)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{toCurrency(row.ramCost, currency)}
|
||||
</TableCell>
|
||||
|
@ -209,6 +213,9 @@ const AllocationReport = ({
|
|||
<TableCell align="right">
|
||||
{toCurrency(row.cpuCost, currency)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{toCurrency(row.gpuCost, currency)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{toCurrency(row.ramCost, currency)}
|
||||
</TableCell>
|
||||
|
@ -231,8 +238,8 @@ const AllocationReport = ({
|
|||
rowsPerPage={rowsPerPage}
|
||||
rowsPerPageOptions={[10, 25, 50]}
|
||||
page={Math.min(page, lastPage)}
|
||||
onChangePage={handleChangePage}
|
||||
onChangeRowsPerPage={handleChangeRowsPerPage}
|
||||
onPageChange={handleChangePage}
|
||||
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -13,7 +13,7 @@ import {
|
|||
TableBody,
|
||||
} from "@material-ui/core";
|
||||
|
||||
import { toCurrency } from "../util";
|
||||
import { toCurrency } from "../../util";
|
||||
import CloudCostChart from "./cloudCostChart";
|
||||
import { CloudCostRow } from "./cloudCostRow";
|
||||
|
||||
|
@ -31,8 +31,6 @@ const CloudCost = ({
|
|||
},
|
||||
});
|
||||
|
||||
|
||||
|
||||
const classes = useStyles();
|
||||
|
||||
function descendingComparator(a, b, orderBy) {
|
||||
|
@ -206,8 +204,8 @@ const CloudCost = ({
|
|||
rowsPerPage={rowsPerPage}
|
||||
rowsPerPageOptions={[10, 25, 50]}
|
||||
page={Math.min(page, lastPage)}
|
||||
onChangePage={handleChangePage}
|
||||
onChangeRowsPerPage={handleChangeRowsPerPage}
|
||||
onPageChange={handleChangePage}
|
||||
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
|
@ -10,8 +10,8 @@ import {
|
|||
ResponsiveContainer,
|
||||
Cell,
|
||||
} from "recharts";
|
||||
import { primary, greyscale, browns } from "../../constants/colors";
|
||||
import { toCurrency } from "../../util";
|
||||
import { primary, greyscale, browns } from "../../../constants/colors";
|
||||
import { toCurrency } from "../../../util";
|
||||
|
||||
const RangeChart = ({ data, currency, height }) => {
|
||||
const useStyles = makeStyles({
|
|
@ -1,6 +1,6 @@
|
|||
import * as React from "react";
|
||||
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 {
|
||||
|
@ -13,8 +13,8 @@ import {
|
|||
BarChart,
|
||||
Bar,
|
||||
} from "recharts";
|
||||
import { toCurrency } from "../util";
|
||||
import cloudCostDayTotals from "../services/cloudCostDayTotals";
|
||||
import { toCurrency } from "../../util";
|
||||
import cloudCostDayTotals from "../../services/cloudCostDayTotals";
|
||||
|
||||
const CloudCostDetails = ({
|
||||
onClose,
|
|
@ -2,8 +2,8 @@ import * as React from "react";
|
|||
|
||||
import { TableCell, TableRow } from "@material-ui/core";
|
||||
|
||||
import { toCurrency } from "../util";
|
||||
import { primary } from "../constants/colors";
|
||||
import { toCurrency } from "../../util";
|
||||
import { primary } from "../../constants/colors";
|
||||
|
||||
const displayCurrencyAsLessThanPenny = (amount, currency) =>
|
||||
amount > 0 && amount < 0.01
|
|
@ -6,7 +6,7 @@ import Select from "@material-ui/core/Select";
|
|||
|
||||
import * as React from "react";
|
||||
|
||||
import SelectWindow from "../../components/SelectWindow";
|
||||
import SelectWindow from "../../SelectWindow";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
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 { useLocation, useHistory } from "react-router";
|
||||
|
||||
import AllocationReport from "./components/allocationReport";
|
||||
import Controls from "./components/Controls";
|
||||
import Header from "./components/Header";
|
||||
import Page from "./components/Page";
|
||||
import Footer from "./components/Footer";
|
||||
import Subtitle from "./components/Subtitle";
|
||||
import Warnings from "./components/Warnings";
|
||||
import AllocationService from "./services/allocation";
|
||||
import AllocationReport from "../components/allocationReport";
|
||||
import Controls from "../components/Controls";
|
||||
import Header from "../components/Header";
|
||||
import Page from "../components/Page";
|
||||
import Footer from "../components/Footer";
|
||||
import Subtitle from "../components/Subtitle";
|
||||
import Warnings from "../components/Warnings";
|
||||
import AllocationService from "../services/allocation";
|
||||
import {
|
||||
checkCustomWindow,
|
||||
cumulativeToTotals,
|
||||
rangeToCumulative,
|
||||
toVerboseTimeRange,
|
||||
} from "./util";
|
||||
import { currencyCodes } from "./constants/currencyCodes";
|
||||
} from "../util";
|
||||
import { currencyCodes } from "../constants/currencyCodes";
|
||||
|
||||
const windowOptions = [
|
||||
{ name: "Today", value: "today" },
|
||||
|
@ -231,7 +231,7 @@ const ReportsPage = () => {
|
|||
}
|
||||
return (
|
||||
<Page active="reports.html">
|
||||
<Header>
|
||||
<Header headerTitle='Cost Allocation'>
|
||||
<IconButton aria-label="refresh" onClick={() => setFetch(true)}>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
|
@ -1,7 +1,7 @@
|
|||
import * as React from "react";
|
||||
import Page from "./components/Page";
|
||||
import Header from "./components/Header";
|
||||
import Footer from "./components/Footer";
|
||||
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";
|
||||
|
@ -10,23 +10,24 @@ import CircularProgress from "@material-ui/core/CircularProgress";
|
|||
import { get, find } from "lodash";
|
||||
import { useLocation, useHistory } from "react-router";
|
||||
|
||||
import { checkCustomWindow, toVerboseTimeRange } from "./util";
|
||||
import CloudCostEditControls from "./cloudCost/controls/cloudCostEditControls";
|
||||
import Subtitle from "./components/Subtitle";
|
||||
import Warnings from "./components/Warnings";
|
||||
import CloudCostTopService from "./services/cloudCostTop";
|
||||
import { checkCustomWindow, toVerboseTimeRange } from "../util";
|
||||
import CloudCostEditControls from "../components/cloudCost/controls/cloudCostEditControls";
|
||||
import Subtitle from "../components/Subtitle";
|
||||
import Warnings from "../components/Warnings";
|
||||
import CloudCostTopService from "../services/cloudCostTop";
|
||||
|
||||
import {
|
||||
windowOptions,
|
||||
costMetricOptions,
|
||||
aggregationOptions,
|
||||
aggMap,
|
||||
} from "./cloudCost/tokens";
|
||||
import { currencyCodes } from "./constants/currencyCodes";
|
||||
import CloudCost from "./cloudCost/cloudCost";
|
||||
import { CloudCostDetails } from "./cloudCost/cloudCostDetails";
|
||||
} from "../components/cloudCost/tokens";
|
||||
|
||||
const CloudCostReports = () => {
|
||||
import { currencyCodes } from "../constants/currencyCodes";
|
||||
import CloudCost from "../components/cloudCost/cloudCost";
|
||||
import { CloudCostDetails } from "../components/cloudCost/cloudCostDetails";
|
||||
|
||||
const CloudCosts = () => {
|
||||
const useStyles = makeStyles({
|
||||
reportHeader: {
|
||||
display: "flex",
|
||||
|
@ -230,7 +231,7 @@ const CloudCostReports = () => {
|
|||
|
||||
return (
|
||||
<Page active="cloud.html">
|
||||
<Header>
|
||||
<Header headerTitle='Cloud Costs'>
|
||||
<IconButton aria-label="refresh" onClick={() => setFetch(true)}>
|
||||
<RefreshIcon />
|
||||
</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 { BrowserRouter as Router, Route, Switch } from "react-router-dom";
|
||||
|
||||
import Reports from "./Reports.js";
|
||||
import CloudCostReports from "./cloudCostReports.js";
|
||||
import Reports from "./pages/Allocations.js";
|
||||
import CloudCosts from "./pages/CloudCosts.js";
|
||||
import ExternalCosts from "./pages/ExternalCosts.js";
|
||||
|
||||
const Routes = () => {
|
||||
return (
|
||||
|
@ -15,7 +16,10 @@ const Routes = () => {
|
|||
<Reports />
|
||||
</Route>
|
||||
<Route exact path="/cloud">
|
||||
<CloudCostReports />
|
||||
<CloudCosts />
|
||||
</Route>
|
||||
<Route exact path="/external-costs">
|
||||
<ExternalCosts />
|
||||
</Route>
|
||||
</Switch>
|
||||
</Router>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import axios from "axios";
|
||||
import { parseFilters } from "../util";
|
||||
import { costMetricToPropName } from "../cloudCost/tokens";
|
||||
import { costMetricToPropName } from "../components/cloudCost/tokens";
|
||||
|
||||
function formatItemsForCost({ data, costType }) {
|
||||
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 { costMetricToPropName } from "./cloudCost/tokens";
|
||||
import { costMetricToPropName } from "./components/cloudCost/tokens";
|
||||
|
||||
// rangeToCumulative takes an AllocationSetRange (type: array[AllocationSet])
|
||||
// and accumulates the values into a single AllocationSet (type: object)
|
||||
|
|
Loading…
Reference in New Issue