diff --git a/.changeset/slow-queens-visit.md b/.changeset/slow-queens-visit.md new file mode 100644 index 0000000000..2df948e477 --- /dev/null +++ b/.changeset/slow-queens-visit.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-opencost': minor +--- + +New OpenCost plugin provides an port of the latest OpenCost UI to Backstage with updated dependencies. The plugin's README covers installation and configuration diff --git a/plugins/opencost/.eslintrc.js b/plugins/opencost/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/opencost/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/opencost/README.md b/plugins/opencost/README.md new file mode 100644 index 0000000000..39c5e7ee2e --- /dev/null +++ b/plugins/opencost/README.md @@ -0,0 +1,79 @@ +# OpenCost + +Welcome to the [OpenCost](https://opencost.io) plugin! + +Currently this is a port of the [OpenCost UI](https://github.com/opencost/opencost/tree/develop/ui), but we will continue to expand it to expose any relevant data or pre-configured views that may be preferred. + +All of the code was originally ported from https://github.com/opencost/opencost/blob/develop/ui/ which is under by the Apache v2 License and also managed by the CNCF. + +## Installation + +1. Add the OpenCost dependency to the `packages/app/package.json`: + ```sh + # From your Backstage root directory + yarn add --cwd packages/app @backstage/plugin-opencost + ``` +2. Add the `OpenCostPage` to your `packages/app/src/App.tsx`: + + ```tsx + import { OpenCostPage } from '@backstage/plugin-opencost'; + ``` + +and + + ```tsx + + … + } /> + + ``` + +3. Add link to OpenCost to your sidebar + + ```typescript + // packages/app/src/components/Root/Root.tsx + import MoneyIcon from '@material-ui/icons/MonetizationOn'; + + ... + + export const Root = ({ children }: PropsWithChildren<{}>) => ( + + + ... + + ... + + + ); + + ``` + +## Plugin Configuration + +Since OpenCost doesn't have any authentication at this point, you just need to give API access to the plugin to access your data. + +If you haven't set up an ingress rule, you can port-forward the API with + +``` +kubectl -n opencost port-forward deployment/opencost 9003 +``` + +Add the following to your `app-config.yaml`: + +```yaml +opencost: + baseUrl: http://localhost:9003 +``` + +## Ideas/Next Steps + +- More testing +- Use the OpenCost mascot for the sidebar logo +- Use the Backstage proxy to communicate with the OpenCost API if necessary for authentication +- Convert AllocationReport.js to use the [Backstage Table](https://backstage.io/storybook/?path=/story/data-display-table--default-table) +- Allow for user-provided default reports and/or disabling controls +- Support multiple hard-coded reports +- clean up deprecation warnings and upgrade to all the latest React components +- Fork(?) to support `Kubecost`, which could provide Alerts and Recommendations, similar to the Cost Explorer plugin + +![Screenshot](screenshot.png) diff --git a/plugins/opencost/api-report.md b/plugins/opencost/api-report.md new file mode 100644 index 0000000000..ee9026cbb7 --- /dev/null +++ b/plugins/opencost/api-report.md @@ -0,0 +1,22 @@ +## API Report File for "@backstage/plugin-opencost" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +/// + +import { BackstagePlugin } from '@backstage/core-plugin-api'; +import { RouteRef } from '@backstage/core-plugin-api'; + +// @public (undocumented) +export const OpenCostPage: () => JSX.Element; + +// @public (undocumented) +export const openCostPlugin: BackstagePlugin< + { + root: RouteRef; + }, + {}, + {} +>; +``` diff --git a/plugins/opencost/config.d.ts b/plugins/opencost/config.d.ts new file mode 100644 index 0000000000..0070bec7b7 --- /dev/null +++ b/plugins/opencost/config.d.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface Config { + /** + * Configurations for the OpenCost plugin + * + * @visibility frontend + */ + opencost?: { + /** + * The base URL for the OpenCost API + * @visibility frontend + */ + baseUrl: string; + }; +} diff --git a/plugins/opencost/dev/index.tsx b/plugins/opencost/dev/index.tsx new file mode 100644 index 0000000000..41b2bd5346 --- /dev/null +++ b/plugins/opencost/dev/index.tsx @@ -0,0 +1,27 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { createDevApp } from '@backstage/dev-utils'; +import { openCostPlugin, OpenCostPage } from '../src/plugin'; + +createDevApp() + .registerPlugin(openCostPlugin) + .addPage({ + element: , + title: 'Root Page', + path: '/opencost', + }) + .render(); diff --git a/plugins/opencost/package.json b/plugins/opencost/package.json new file mode 100644 index 0000000000..af2a275a52 --- /dev/null +++ b/plugins/opencost/package.json @@ -0,0 +1,60 @@ +{ + "name": "@backstage/plugin-opencost", + "version": "0.1.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "frontend-plugin" + }, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/core-components": "workspace:^", + "@backstage/core-plugin-api": "workspace:^", + "@backstage/theme": "workspace:^", + "@date-io/luxon": "1.x", + "@material-ui/core": "^4.9.13", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "^4.0.0-alpha.60", + "@material-ui/pickers": "^3.3.10", + "@material-ui/styles": "^4.11.5", + "axios": "^1.4.0", + "date-fns": "^2.30.0", + "lodash": "^4.17.21", + "react-use": "^17.2.4", + "recharts": "^2.5.0" + }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0", + "react-router-dom": "*" + }, + "devDependencies": { + "@backstage/cli": "workspace:^", + "@backstage/core-app-api": "workspace:^", + "@backstage/dev-utils": "workspace:^", + "@backstage/test-utils": "workspace:^", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^12.1.3", + "@testing-library/user-event": "^14.0.0", + "cross-fetch": "^3.1.5", + "msw": "^1.0.0" + }, + "files": [ + "dist", + "config.d.ts" + ], + "configSchema": "config.d.ts" +} diff --git a/plugins/opencost/screenshot.png b/plugins/opencost/screenshot.png new file mode 100644 index 0000000000..432b525cb1 Binary files /dev/null and b/plugins/opencost/screenshot.png differ diff --git a/plugins/opencost/src/components/AllocationChart/RangeChart.jsx b/plugins/opencost/src/components/AllocationChart/RangeChart.jsx new file mode 100644 index 0000000000..b1ce5a34ee --- /dev/null +++ b/plugins/opencost/src/components/AllocationChart/RangeChart.jsx @@ -0,0 +1,201 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from 'recharts'; +import { makeStyles } from '@material-ui/styles'; +import { reverse } from 'lodash'; +import { primary, greyscale, browns } from '../../constants/colors'; +import { toCurrency } from '../../util'; + +const useStyles = makeStyles({ + tooltip: { + borderRadius: 2, + background: 'rgba(255, 255, 255, 0.95)', + padding: 12, + }, + tooltipLineItem: { + fontSize: '1rem', + margin: 0, + marginBottom: 4, + padding: 0, + }, +}); + +function toBarLabels(allocationRange) { + const keyToFill = {}; + let p = 0; + let g = 0; + let b = 0; + + for (const { idle } of allocationRange) { + for (const allocation of idle) { + const key = allocation.name; + if (keyToFill[key] === undefined) { + // idle allocations are assigned grey + keyToFill[key] = greyscale[g]; + g = (g + 1) % greyscale.length; + } + } + } + + for (const { top } of allocationRange) { + for (const allocation of top) { + const key = allocation.name; + if (keyToFill[key] === undefined) { + if (key === '__unallocated__') { + // unallocated gets black (clean up) + keyToFill[key] = '#212121'; + } else { + // non-idle allocations get the next available color + keyToFill[key] = primary[p]; + p = (p + 1) % primary.length; + } + } + } + } + + for (const { other } of allocationRange) { + for (const allocation of other) { + const key = allocation.name; + if (keyToFill[key] === undefined) { + // idle allocations are assigned grey + keyToFill[key] = browns[b]; + b = (b + 1) % browns.length; + } + } + } + + const labels = []; + for (const key in keyToFill) { + if (Object.hasOwn(keyToFill, key)) { + labels.push({ + dataKey: key, + fill: keyToFill[key], + }); + } + } + + return reverse(labels); +} + +function toBar(datum) { + const { top, other, idle } = datum; + const bar = {}; + + for (const key in top) { + if (Object.hasOwn(top, key)) { + const allocation = top[key]; + const start = new Date(allocation.start); + bar.start = `${start.getUTCFullYear()}-${ + start.getUTCMonth() + 1 + }-${start.getUTCDate()}`; + bar[allocation.name] = allocation.totalCost; + } + } + + for (const key in other) { + if (Object.hasOwn(other, key)) { + const allocation = other[key]; + const start = new Date(allocation.start); + bar.start = `${start.getUTCFullYear()}-${ + start.getUTCMonth() + 1 + }-${start.getUTCDate()}`; + bar[allocation.name] = allocation.totalCost; + } + } + + for (const key in idle) { + if (Object.hasOwn(idle, key)) { + const allocation = idle[key]; + const start = new Date(allocation.start); + bar.start = `${start.getUTCFullYear()}-${ + start.getUTCMonth() + 1 + }-${start.getUTCDate()}`; + bar[allocation.name] = allocation.totalCost; + } + } + + return bar; +} + +const RangeChart = ({ data, currency, height }) => { + const classes = useStyles(); + + const barData = data.map(toBar); + const barLabels = toBarLabels(data); + + const CustomTooltip = params => { + const { active, payload } = params; + + if (!payload || payload.length === 0) { + return null; + } + + const total = payload.reduce((sum, item) => sum + item.value, 0.0); + if (active) { + /* eslint react/forbid-elements: [0, { allow: ["warning"] }] */ + return ( +
+

{`Total: ${toCurrency(total, currency)}`}

+ {reverse(payload).map((item, i) => ( +

{`${item.name}: ${toCurrency(item.value, currency)}`}

+ ))} +
+ ); + } + + return null; + }; + + return ( + + + + + + } /> + {barLabels.map((barLabel, i) => ( + + ))} + + + ); +}; + +export default RangeChart; diff --git a/plugins/opencost/src/components/AllocationChart/SummaryChart.jsx b/plugins/opencost/src/components/AllocationChart/SummaryChart.jsx new file mode 100644 index 0000000000..53c0842845 --- /dev/null +++ b/plugins/opencost/src/components/AllocationChart/SummaryChart.jsx @@ -0,0 +1,127 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'; +import { primary, greyscale, browns } from '../../constants/colors'; +import { toCurrency } from '../../util'; + +function toPieData(top, other, idle) { + const slices = []; + + for (const i in top) { + if (Object.hasOwn(top, i)) { + const allocation = top[i]; + const fill = + allocation.name === '__unallocated__' + ? '#212121' + : primary[i % primary.length]; + + slices.push({ + name: allocation.name, + value: allocation.totalCost, + fill: fill, + }); + } + } + + for (const i in other) { + if (Object.hasOwn(other, i)) { + const allocation = other[i]; + const fill = browns[i % browns.length]; + slices.push({ + name: allocation.name, + value: allocation.totalCost, + fill: fill, + }); + } + } + + for (const i in idle) { + if (Object.hasOwn(idle, i)) { + const allocation = idle[i]; + const fill = greyscale[i % greyscale.length]; + slices.push({ + name: allocation.name, + value: allocation.totalCost, + fill: fill, + }); + } + } + + return slices; +} + +const SummaryChart = ({ top, other, idle, currency, height }) => { + const pieData = toPieData(top, other, idle); + + const renderLabel = params => { + const { cx, cy, midAngle, outerRadius, percent, name, fill, value } = + params; + + const RADIAN = Math.PI / 180; + const radius = outerRadius * 1.1; + let x = cx + radius * Math.cos(-midAngle * RADIAN); + x += x > cx ? 2 : -2; + const y = cy + radius * Math.sin(-midAngle * RADIAN); + // y -= Math.min(Math.abs(2 / Math.cos(-midAngle * RADIAN)), 8) + + if (percent < 0.02) { + return undefined; + } + + return ( + cx ? 'start' : 'end'} + dominantBaseline="central" + > + {`${name}: ${toCurrency(value, currency)} (${(percent * 100).toFixed( + 1, + )}%)`} + + ); + }; + + return ( + + + + {pieData.map((datum, i) => ( + + ))} + + + + ); +}; + +export default SummaryChart; diff --git a/plugins/opencost/src/components/AllocationChart/index.jsx b/plugins/opencost/src/components/AllocationChart/index.jsx new file mode 100644 index 0000000000..9fec3e296f --- /dev/null +++ b/plugins/opencost/src/components/AllocationChart/index.jsx @@ -0,0 +1,108 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { isArray, filter, map, reduce, reverse, sortBy } from 'lodash'; + +import Typography from '@material-ui/core/Typography'; + +import RangeChart from './RangeChart'; +import SummaryChart from './SummaryChart'; + +// TODO niko/etl +/* eslint @typescript-eslint/no-shadow: ["error", { "allow": ["agg"] }]*/ +function agg(allocationSet, name) { + if (allocationSet.length === 0) { + return null; + } + + return reduce( + allocationSet, + (agg, cur) => ({ + name: agg.name, + aggregatedBy: cur.aggregatedBy, + properties: agg.properties, + start: cur.start, + end: cur.end, + cpuCost: agg.cpuCost + cur.cpuCost, + gpuCost: agg.gpuCost + cur.gpuCost, + ramCost: agg.ramCost + cur.ramCost, + pvCost: agg.pvCost + cur.pvCost, + totalCost: agg.totalCost + cur.totalCost, + count: agg.count + 1, + }), + { + name: name, + properties: null, + cpuCost: 0.0, + gpuCost: 0.0, + ramCost: 0.0, + pvCost: 0.0, + totalCost: 0.0, + count: 0, + }, + ); +} + +function isIdle(allocation) { + return allocation.name.indexOf('__idle__') >= 0; +} + +function top(n, by) { + return allocations => { + if (isArray(allocations[0])) { + return map(allocations, top(n, by)); + } + + const sorted = reverse(sortBy(allocations, by)); + const active = filter(sorted, a => !isIdle(a)); + const idle = filter(sorted, a => isIdle(a)); + const topn = active.slice(0, n); + const other = []; + if (active.length > n) { + other.push(agg(active.slice(n), 'other')); + } + + return { + top: topn, + other: other, + idle: idle, + }; + }; +} + +const AllocationChart = ({ allocationRange, currency, n, height }) => { + if (allocationRange.length === 0) { + return No data; + } + + if (allocationRange.length === 1) { + const datum = top(n, alloc => alloc.totalCost)(allocationRange[0]); + return ( + + ); + } + + const data = top(n, alloc => alloc.totalCost)(allocationRange); + return ; +}; + +export default React.memo(AllocationChart); diff --git a/plugins/opencost/src/components/AllocationReport.jsx b/plugins/opencost/src/components/AllocationReport.jsx new file mode 100644 index 0000000000..52686a741c --- /dev/null +++ b/plugins/opencost/src/components/AllocationReport.jsx @@ -0,0 +1,261 @@ +/* not changing the working code at this point */ +/* eslint no-nested-ternary: 0 */ +/* eslint react-hooks/rules-of-hooks: 0 */ + +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useEffect, useState } from 'react'; +import { get, round } from 'lodash'; +import { makeStyles } from '@material-ui/styles'; +import Table from '@material-ui/core/Table'; +import TableBody from '@material-ui/core/TableBody'; +import TableCell from '@material-ui/core/TableCell'; +import TableContainer from '@material-ui/core/TableContainer'; +import TableHead from '@material-ui/core/TableHead'; +import TablePagination from '@material-ui/core/TablePagination'; +import TableRow from '@material-ui/core/TableRow'; +import TableSortLabel from '@material-ui/core/TableSortLabel'; +import Typography from '@material-ui/core/Typography'; +import AllocationChart from './AllocationChart'; +import { toCurrency } from '../util'; + +const useStyles = makeStyles({ + noResults: { + padding: 24, + }, +}); + +function descendingComparator(a, b, orderBy) { + if (get(b, orderBy) < get(a, orderBy)) { + return -1; + } + if (get(b, orderBy) > get(a, orderBy)) { + return 1; + } + return 0; +} + +function getComparator(order, orderBy) { + return order === 'desc' + ? (a, b) => descendingComparator(a, b, orderBy) + : (a, b) => -descendingComparator(a, b, orderBy); +} + +function stableSort(array, comparator) { + const stabilizedThis = array.map((el, index) => [el, index]); + stabilizedThis.sort((a, b) => { + const order = comparator(a[0], b[0]); + if (order !== 0) return order; + return a[1] - b[1]; + }); + return stabilizedThis.map(el => el[0]); +} + +const headCells = [ + { id: 'name', numeric: false, label: 'Name', width: 'auto' }, + { id: 'cpuCost', numeric: true, label: 'CPU', width: 100 }, + { id: 'ramCost', numeric: true, label: 'RAM', width: 100 }, + { id: 'pvCost', numeric: true, label: 'PV', width: 100 }, + { id: 'totalEfficiency', numeric: true, label: 'Efficiency', width: 130 }, + { id: 'totalCost', numeric: true, label: 'Total cost', width: 130 }, +]; + +const AllocationReport = ({ + allocationData, + cumulativeData, + totalData, + currency, +}) => { + const classes = useStyles(); + + if (allocationData.length === 0) { + return ( + + No results + + ); + } + + const [order, setOrder] = React.useState('desc'); + const [orderBy, setOrderBy] = React.useState('totalCost'); + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(25); + const numData = cumulativeData.length; + + useEffect(() => { + setPage(0); + }, [numData]); + + const lastPage = Math.floor(numData / rowsPerPage); + + const handleChangePage = (event, newPage) => setPage(newPage); + + const handleChangeRowsPerPage = event => { + setRowsPerPage(parseInt(event.target.value, 10)); + setPage(0); + }; + + const handleRequestSort = (event, property) => { + const isDesc = orderBy === property && order === 'desc'; + setOrder(isDesc ? 'asc' : 'desc'); + setOrderBy(property); + }; + + const createSortHandler = property => event => + handleRequestSort(event, property); + + const orderedRows = stableSort(cumulativeData, getComparator(order, orderBy)); + const pageRows = orderedRows.slice( + page * rowsPerPage, + page * rowsPerPage + rowsPerPage, + ); + + return ( +
+ + + + + + {headCells.map(cell => ( + + + {cell.label} + + + ))} + + + + + {headCells.map(cell => { + return ( + + {cell.numeric + ? cell.label === 'Efficiency' + ? totalData.totalEfficiency === 1.0 && + totalData.cpuReqCoreHrs === 0 && + totalData.ramReqByteHrs === 0 + ? 'Inf%' + : `${round(totalData.totalEfficiency * 100, 1)}%` + : toCurrency(totalData[cell.id], currency) + : totalData[cell.id]} + + ); + })} + + {pageRows.map((row, key) => { + if (row.name === '__unmounted__') { + row.name = 'Unmounted PVs'; + } + + const isIdle = row.name.indexOf('__idle__') >= 0; + const isUnallocated = row.name.indexOf('__unallocated__') >= 0; + const isUnmounted = row.name.indexOf('Unmounted PVs') >= 0; + + // Replace "efficiency" with Inf if there is usage w/o request + let efficiency = round(row.totalEfficiency * 100, 1); + if ( + row.totalEfficiency === 1.0 && + row.cpuReqCoreHrs === 0 && + row.ramReqByteHrs === 0 + ) { + efficiency = 'Inf'; + } + + // Do not allow drill-down for idle and unallocated rows + if (isIdle || isUnallocated || isUnmounted) { + return ( + + {row.name} + + {toCurrency(row.cpuCost, currency)} + + + {toCurrency(row.ramCost, currency)} + + + {toCurrency(row.pvCost, currency)} + + {isIdle ? ( + + ) : ( + {efficiency}% + )} + + {toCurrency(row.totalCost, currency)} + + + ); + } + + return ( + + {row.name} + + {toCurrency(row.cpuCost, currency)} + + + {toCurrency(row.ramCost, currency)} + + + {toCurrency(row.pvCost, currency)} + + {efficiency}% + + {toCurrency(row.totalCost, currency)} + + + ); + })} + +
+
+ +
+ ); +}; + +export default React.memo(AllocationReport); diff --git a/plugins/opencost/src/components/Controls/Download.jsx b/plugins/opencost/src/components/Controls/Download.jsx new file mode 100644 index 0000000000..727188e5c1 --- /dev/null +++ b/plugins/opencost/src/components/Controls/Download.jsx @@ -0,0 +1,110 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { get, forEach, reverse, round, sortBy } from 'lodash'; +import ExportIcon from '@material-ui/icons/GetApp'; +import IconButton from '@material-ui/core/IconButton'; +import Tooltip from '@material-ui/core/Tooltip'; + +const columns = [ + { + head: 'Name', + prop: 'name', + currency: false, + }, + { + head: 'CPU', + prop: 'cpuCost', + currency: true, + }, + { + head: 'GPU', + prop: 'gpuCost', + currency: true, + }, + { + head: 'RAM', + prop: 'ramCost', + currency: true, + }, + { + head: 'PV', + prop: 'pvCost', + currency: true, + }, + { + head: 'Network', + prop: 'networkCost', + currency: true, + }, + { + head: 'Shared', + prop: 'sharedCost', + currency: true, + }, + { + head: 'Total', + prop: 'totalCost', + currency: true, + }, +]; + +const toCSVLine = datum => { + const cols = []; + + forEach(columns, c => { + if (c.currency) { + cols.push(round(get(datum, c.prop, 0.0), 2)); + } else { + cols.push(`"${get(datum, c.prop, '')}"`); + } + }); + + return cols.join(','); +}; + +const DownloadControl = ({ cumulativeData, title }) => { + // downloadReport downloads a CSV of the cumulative allocation data + function downloadReport() { + // Build CSV + const head = columns.map(c => c.head).join(','); + const body = reverse(sortBy(cumulativeData, 'totalCost')) + .map(toCSVLine) + .join('\r\n'); + const csv = `${head}\r\n${body}`; + + // Create download link + const a = document.createElement('a'); + a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' })); + const filename = title.toLocaleLowerCase('en-US').replace(/\s/gi, '-'); + a.setAttribute('download', `${filename}-${Date.now()}.csv`); + + // Click the link + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + } + + return ( + + + + + + ); +}; + +export default React.memo(DownloadControl); diff --git a/plugins/opencost/src/components/Controls/Edit.jsx b/plugins/opencost/src/components/Controls/Edit.jsx new file mode 100644 index 0000000000..f02f8cc111 --- /dev/null +++ b/plugins/opencost/src/components/Controls/Edit.jsx @@ -0,0 +1,106 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +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 React from 'react'; + +import SelectWindow from '../SelectWindow'; + +const useStyles = makeStyles({ + wrapper: { + display: 'inline-flex', + }, + formControl: { + margin: 8, + minWidth: 120, + }, +}); + +function EditControl({ + windowOptions, + window, + setWindow, + aggregationOptions, + aggregateBy, + setAggregateBy, + accumulateOptions, + accumulate, + setAccumulate, + currencyOptions, + currency, + setCurrency, +}) { + const classes = useStyles(); + return ( +
+ + + Breakdown + + + + Resolution + + + + Currency + + +
+ ); +} + +export default React.memo(EditControl); diff --git a/plugins/opencost/src/components/Controls/index.jsx b/plugins/opencost/src/components/Controls/index.jsx new file mode 100644 index 0000000000..c63833a6ab --- /dev/null +++ b/plugins/opencost/src/components/Controls/index.jsx @@ -0,0 +1,57 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import DownloadControl from './Download'; +import EditControl from './Edit'; + +const Controls = ({ + windowOptions, + window, + setWindow, + aggregationOptions, + aggregateBy, + setAggregateBy, + accumulateOptions, + accumulate, + setAccumulate, + title, + cumulativeData, + currency, + currencyOptions, + setCurrency, +}) => { + return ( +
+ + +
+ ); +}; + +export default React.memo(Controls); diff --git a/plugins/opencost/src/components/Details.js b/plugins/opencost/src/components/Details.js new file mode 100644 index 0000000000..5245c9e9fa --- /dev/null +++ b/plugins/opencost/src/components/Details.js @@ -0,0 +1,287 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { memo, useEffect, useState } from 'react'; +import { forEach, get, reverse, round, sortBy } from 'lodash'; +import CircularProgress from '@material-ui/core/CircularProgress'; +import ClusterIcon from '@material-ui/icons/GroupWork'; +import NodeIcon from '@material-ui/icons/Memory'; +import List from '@material-ui/core/List'; +import ListItem from '@material-ui/core/ListItem'; +import ListItemIcon from '@material-ui/core/ListItemIcon'; +import ListItemText from '@material-ui/core/ListItemText'; +import Table from '@material-ui/core/Table'; +import TableBody from '@material-ui/core/TableBody'; +import TableCell from '@material-ui/core/TableCell'; +import TableContainer from '@material-ui/core/TableContainer'; +import TableHead from '@material-ui/core/TableHead'; +import TableRow from '@material-ui/core/TableRow'; +import Warnings from './Warnings'; +import AllocationService from '../services/allocation'; +import { bytesToString, toCurrency } from '../util'; + +const Details = ({ + window, + namespace, + controllerKind, + controller, + pod, + currency, +}) => { + const [cluster, setCluster] = useState(''); + const [node, setNode] = useState(''); + + const [fetch, setFetch] = useState(true); + const [loading, setLoading] = useState(false); + const [errors, setErrors] = useState([]); + const [rows, setRows] = useState([]); + + useEffect(() => { + if (fetch) { + setCluster(''); + setNode(''); + fetchData(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fetch]); + + async function fetchData() { + setLoading(true); + setErrors([]); + + try { + const filters = []; + + if (cluster) { + filters.push({ + property: 'cluster', + value: cluster, + }); + } + + if (node) { + filters.push({ + property: 'node', + value: node, + }); + } + + if (namespace) { + filters.push({ + property: 'namespace', + value: namespace, + }); + } + + if (controllerKind) { + filters.push({ + property: 'controllerKind', + value: controllerKind, + }); + } + + if (controller) { + filters.push({ + property: 'controller', + value: controller, + }); + } + + if (pod) { + filters.push({ + property: 'pod', + value: pod, + }); + } + + const resp = await AllocationService.fetchAllocation(window, '', { + accumulate: true, + }); + + let data = []; + forEach(resp.data[0], datum => { + if (datum.name === '__idle__') { + return; + } + + if (!cluster) { + setCluster(get(datum, 'properties.cluster', '')); + } + + if (!node) { + setNode(get(datum, 'properties.node', '')); + } + + // TODO can we get pod, container back in properties? + const names = datum.name.split('/'); + datum.pod = names[names.length - 2]; + datum.container = names[names.length - 1]; + + datum.hours = round(get(datum, 'minutes', 0.0) / 60.0, 2); + + if (datum.hours > 0) { + datum.cpu = round(get(datum, 'cpuCoreHours', 0.0) / datum.hours, 2); + datum.cpuCostPerCoreHr = datum.cpuCost / (datum.cpu * datum.hours); + if (datum.cpu === 0) { + datum.cpuCostPerCoreHr = 0.0; + } + + datum.ram = round(get(datum, 'ramByteHours', 0.0) / datum.hours, 2); + const ramGiB = datum.ram / 1024 / 1024 / 1024; + datum.ramCostPerGiBHr = datum.ramCost / (ramGiB * datum.hours); + if (ramGiB === 0) { + datum.ramCostPerGiBHr = 0.0; + } + } else { + datum.cpu = 0.0; + datum.cpuCostPerCoreHr = 0.0; + datum.ram = 0.0; + datum.ramCostPerGiBHr = 0.0; + } + + data.push(datum); + }); + + data = reverse(sortBy(data, 'totalCost')); + + setRows(data); + } catch (e) { + /* eslint no-console: ["error", { allow: ["warn"] }] */ + console.warn( + `Error fetching details for (${controllerKind}, ${controller}):`, + e, + ); + setErrors([ + { + primary: 'Error fetching details', + secondary: `Tried fetching details for: ${namespace}, ${controllerKind}, ${controller}, ${pod}`, + }, + ]); + } + + setLoading(false); + setFetch(false); + } + + if (loading) { + return ( +
+
+ +
+
+ ); + } + + return ( +
+ {!loading && errors.length > 0 && ( +
+ +
+ )} + + + {cluster && ( + + + + + + + )} + {node && ( + + + + + + + )} + + + + + + + Container + + + Hours + + + CPU + + + $/(CPU*Hr) + + + CPU cost + + + RAM + + + $/(GiB*Hr) + + + RAM cost + + + Total cost + + + + + {rows.map((row, i) => ( + + + {row.container} + + + {row.hours} + + + {row.cpu} + + + {toCurrency(row.cpuCostPerCoreHr, currency, 5)} + + + {toCurrency(row.cpuCost, currency, 3)} + + + {bytesToString(row.ram)} + + + {toCurrency(row.ramCostPerGiBHr, currency, 5)} + + + {toCurrency(row.ramCost, currency, 3)} + + + {toCurrency(row.totalCost, currency, 3)} + + + ))} + +
+
+
+ ); +}; + +export default memo(Details); diff --git a/plugins/opencost/src/components/OpenCostPage/OpenCostPage.tsx b/plugins/opencost/src/components/OpenCostPage/OpenCostPage.tsx new file mode 100644 index 0000000000..bd60fa0c35 --- /dev/null +++ b/plugins/opencost/src/components/OpenCostPage/OpenCostPage.tsx @@ -0,0 +1,44 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { Grid } from '@material-ui/core'; +import { Header, Page, Content } from '@backstage/core-components'; +import { OpenCostReport } from '../OpenCostReport'; + +export const OpenCostPage = () => ( + +
+ + OpenCost + +
+ + + + + + + +
+); diff --git a/plugins/opencost/src/components/OpenCostPage/index.ts b/plugins/opencost/src/components/OpenCostPage/index.ts new file mode 100644 index 0000000000..f966c83a30 --- /dev/null +++ b/plugins/opencost/src/components/OpenCostPage/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { OpenCostPage } from './OpenCostPage'; diff --git a/plugins/opencost/src/components/OpenCostReport/OpenCostReport.tsx b/plugins/opencost/src/components/OpenCostReport/OpenCostReport.tsx new file mode 100644 index 0000000000..12f64f3606 --- /dev/null +++ b/plugins/opencost/src/components/OpenCostReport/OpenCostReport.tsx @@ -0,0 +1,319 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +// code ported from https://github.com/opencost/opencost/blob/develop/ui/src/Reports.js + +import React, { useEffect, useState } from 'react'; +import CircularProgress from '@material-ui/core/CircularProgress'; +import IconButton from '@material-ui/core/IconButton'; +import Paper from '@material-ui/core/Paper'; +import RefreshIcon from '@material-ui/icons/Refresh'; +import Typography from '@material-ui/core/Typography'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { find, get, sortBy, toArray } from 'lodash'; +import { makeStyles } from '@material-ui/styles'; +import AllocationReport from '../AllocationReport'; +import AllocationService from '../../services/allocation'; +import Controls from '../Controls'; +import Page from '../Page'; +import Subtitle from '../Subtitle'; +import Warnings from '../Warnings'; +import { + checkCustomWindow, + cumulativeToTotals, + rangeToCumulative, + toVerboseTimeRange, +} from '../../util'; +import { currencyCodes } from '../../constants/currencyCodes'; +import { useApi, configApiRef } from '@backstage/core-plugin-api'; + +const windowOptions = [ + { name: 'Today', value: 'today' }, + { name: 'Yesterday', value: 'yesterday' }, + { name: 'Week-to-date', value: 'week' }, + { name: 'Month-to-date', value: 'month' }, + { name: 'Last week', value: 'lastweek' }, + { name: 'Last month', value: 'lastmonth' }, + { name: 'Last 7 days', value: '6d' }, + { name: 'Last 30 days', value: '29d' }, + { name: 'Last 60 days', value: '59d' }, + { name: 'Last 90 days', value: '89d' }, +]; + +const aggregationOptions = [ + { name: 'Cluster', value: 'cluster' }, + { name: 'Node', value: 'node' }, + { name: 'Namespace', value: 'namespace' }, + { name: 'Controller kind', value: 'controllerKind' }, + { name: 'Controller', value: 'controller' }, + { name: 'Service', value: 'service' }, + { name: 'Pod', value: 'pod' }, + { name: 'Container', value: 'container' }, +]; + +const accumulateOptions = [ + { name: 'Entire window', value: true }, + { name: 'Daily', value: false }, +]; + +const useStyles = makeStyles({ + reportHeader: { + display: 'flex', + flexFlow: 'row', + padding: 24, + }, + titles: { + flexGrow: 1, + }, +}); + +// generateTitle generates a string title from a report object +// @ts-ignore: implicitly has an 'any' type +function generateTitle({ window, aggregateBy, accumulate }) { + let windowName = get(find(windowOptions, { value: window }), 'name', ''); + if (windowName === '') { + if (checkCustomWindow(window)) { + windowName = toVerboseTimeRange(window); + } else { + /* eslint no-console: ["error", { allow: ["warn"] }] */ + console.warn(`unknown window: ${window}`); + } + } + + const aggregationName = get( + find(aggregationOptions, { value: aggregateBy }), + 'name', + '', + ).toLocaleLowerCase('en-US'); + if (aggregationName === '') { + /* eslint no-console: ["error", { allow: ["warn"] }] */ + console.warn(`unknown aggregation: ${aggregateBy}`); + } + + let str = `${windowName} by ${aggregationName}`; + + if (!accumulate) { + str = `${str} daily`; + } + + return str; +} + +export const OpenCostReport = () => { + const classes = useStyles(); + // Allocation data state + const [allocationData, setAllocationData] = useState([]); + const [cumulativeData, setCumulativeData] = useState({}); + const [totalData, setTotalData] = useState({}); + + // When allocation data changes, create a cumulative version of it + useEffect(() => { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + const cumulative = rangeToCumulative(allocationData, aggregateBy); + setCumulativeData(toArray(cumulative)); + setTotalData(cumulativeToTotals(cumulative)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [allocationData]); + + // Form state, which controls form elements, but not the report itself. On + // certain actions, the form state may flow into the report state. + const [window, setWindow] = useState(windowOptions[0].value); + const [aggregateBy, setAggregateBy] = useState(aggregationOptions[0].value); + const [accumulate, setAccumulate] = useState(accumulateOptions[0].value); + const [currency, setCurrency] = useState('USD'); + + // Report state, including current report and saved options + const [title, setTitle] = useState('Last 7 days by namespace daily'); + + // When parameters changes, fetch data. This should be the + // only mechanism used to fetch data. Also generate a sensible title from the paramters. + useEffect(() => { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + setFetch(true); + setTitle(generateTitle({ window, aggregateBy, accumulate })); + }, [window, aggregateBy, accumulate]); + + // page and settings state + const [init, setInit] = useState(false); + const [fetch, setFetch] = useState(false); + const [loading, setLoading] = useState(true); + const [errors, setErrors] = useState([]); + + // Initialize once, then fetch report each time setFetch(true) is called + useEffect(() => { + if (!init) { + initialize(); + } + if (init && fetch) { + fetchData(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [init, fetch]); + + // parse any context information from the URL + const routerLocation = useLocation(); + const searchParams = new URLSearchParams(routerLocation.search); + const routerNavigate = useNavigate(); + useEffect(() => { + setWindow(searchParams.get('window') || '6d'); + setAggregateBy(searchParams.get('agg') || 'namespace'); + setAccumulate(searchParams.get('acc') === 'true' || false); + setCurrency(searchParams.get('currency') || 'USD'); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [routerLocation]); + + async function initialize() { + setInit(true); + } + + const configApi = useApi(configApiRef); + const baseUrl = configApi.getConfig('opencost').getString('baseUrl'); + /* eslint no-console: 0 */ + console.log(`baseUrl:${baseUrl}`); + + async function fetchData() { + setLoading(true); + setErrors([]); + + try { + const resp = await AllocationService.fetchAllocation( + baseUrl, + window, + aggregateBy, + { accumulate }, + ); + if (resp.data && resp.data.length > 0) { + const allocationRange = resp.data; + for (const i in allocationRange) { + if (Object.hasOwn(allocationRange, i)) { + // update cluster aggregations to use clusterName/clusterId names + allocationRange[i] = sortBy(allocationRange[i], a => a.totalCost); + } + } + setAllocationData(allocationRange); + } else { + setAllocationData([]); + } + } catch (err) { + if (err.message.indexOf('404') === 0) { + setErrors([ + // @ts-ignore: not assignable to type 'never' + { + primary: 'Failed to load report data', + secondary: + 'Please update OpenCost to the latest version, then open an Issue on GitHub if problems persist.', + }, + ]); + } else { + let secondary = + 'Please open an OpenCost issue with a bug report if problems persist.'; + if (err.message.length > 0) { + secondary = err.message; + } + setErrors([ + // @ts-ignore: not assignable to type 'never' + { + primary: 'Failed to load report data', + secondary: secondary, + }, + ]); + } + setAllocationData([]); + } + + setLoading(false); + setFetch(false); + } + return ( + + {!loading && errors.length > 0 && ( +
+ +
+ )} + {init && ( + +
+
+ {title} + +
+ + setFetch(true)}> + + + + { + searchParams.set('window', win); + routerNavigate({ + search: `?${searchParams.toString()}`, + }); + }} + aggregationOptions={aggregationOptions} + aggregateBy={aggregateBy} + // @ts-ignore: implicitly has an 'any' type + setAggregateBy={agg => { + searchParams.set('agg', agg); + routerNavigate({ + search: `?${searchParams.toString()}`, + }); + }} + accumulateOptions={accumulateOptions} + accumulate={accumulate} + // @ts-ignore: implicitly has an 'any' type + setAccumulate={acc => { + searchParams.set('acc', acc); + routerNavigate({ + search: `?${searchParams.toString()}`, + }); + }} + title={title} + cumulativeData={cumulativeData} + currency={currency} + currencyOptions={currencyCodes} + // @ts-ignore: implicitly has an 'any' type + setCurrency={curr => { + searchParams.set('currency', curr); + routerNavigate({ + search: `?${searchParams.toString()}`, + }); + }} + /> +
+ + {loading && ( +
+
+ +
+
+ )} + {!loading && ( + + )} +
+ )} +
+ ); +}; diff --git a/plugins/opencost/src/components/OpenCostReport/index.ts b/plugins/opencost/src/components/OpenCostReport/index.ts new file mode 100644 index 0000000000..aee85ea8db --- /dev/null +++ b/plugins/opencost/src/components/OpenCostReport/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { OpenCostReport } from './OpenCostReport'; diff --git a/plugins/opencost/src/components/Page.jsx b/plugins/opencost/src/components/Page.jsx new file mode 100644 index 0000000000..6346b6287b --- /dev/null +++ b/plugins/opencost/src/components/Page.jsx @@ -0,0 +1,47 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { makeStyles } from '@material-ui/styles'; +import React from 'react'; + +const useStyles = makeStyles({ + wrapper: { + display: 'flex', + flexFlow: 'column', + flexGrow: 1, + margin: '20px 30px 0 30px', + minWidth: 800, + }, + flexGrow: { + display: 'flex', + flexFlow: 'column', + flexGrow: 1, + }, +}); + +const Page = props => { + const classes = useStyles(); + + return ( +
+
+
{props.children}
+
+
+ ); +}; + +export default Page; diff --git a/plugins/opencost/src/components/SelectWindow.jsx b/plugins/opencost/src/components/SelectWindow.jsx new file mode 100644 index 0000000000..e374571c00 --- /dev/null +++ b/plugins/opencost/src/components/SelectWindow.jsx @@ -0,0 +1,214 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useEffect, useState } from 'react'; +import { makeStyles } from '@material-ui/styles'; +import { endOfDay, startOfDay } from '@date-io/luxon'; +import { + MuiPickersUtilsProvider, + KeyboardDatePicker, +} from '@material-ui/pickers'; +import Button from '@material-ui/core/Button'; +import LuxonUtils from '@date-io/luxon'; +import FormControl from '@material-ui/core/FormControl'; +import Link from '@material-ui/core/Link'; +import Popover from '@material-ui/core/Popover'; +import TextField from '@material-ui/core/TextField'; +import Typography from '@material-ui/core/Typography'; +import { isValid } from 'date-fns'; +import { find, get } from 'lodash'; + +const useStyles = makeStyles({ + dateContainer: { + paddingLeft: 18, + paddingRight: 18, + paddingTop: 6, + paddingBottom: 18, + display: 'flex', + flexFlow: 'row', + }, + dateContainerColumn: { + display: 'flex', + flexFlow: 'column', + }, + formControl: { + margin: 8, + width: 120, + }, +}); + +const SelectWindow = ({ windowOptions, window, setWindow }) => { + const classes = useStyles(); + const [anchorEl, setAnchorEl] = useState(null); + + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + const [intervalString, setIntervalString] = useState(null); + + const handleClick = event => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const handleStartDateChange = date => { + if (isValid(date)) { + setStartDate(startOfDay(date)); + } + }; + + const handleEndDateChange = date => { + if (isValid(date)) { + setEndDate(endOfDay(date)); + } + }; + + const handleSubmitPresetDates = dateString => { + setWindow(dateString); + setStartDate(null); + setEndDate(null); + handleClose(); + }; + + const handleSubmitCustomDates = () => { + if (intervalString !== null) { + setWindow(intervalString); + handleClose(); + } + }; + + useEffect(() => { + if (startDate !== null && endDate !== null) { + // Note: getTimezoneOffset() is calculated based on current system locale, NOT date object + const adjustedStartDate = new Date( + startDate - startDate.getTimezoneOffset() * 60000, + ); + const adjustedEndDate = new Date( + endDate - endDate.getTimezoneOffset() * 60000, + ); + setIntervalString( + `${adjustedStartDate.toISOString().split('.')[0]}Z` + + `,${adjustedEndDate.toISOString().split('.')[0]}Z`, + ); + } + }, [startDate, endDate]); + + const open = Boolean(anchorEl); + const id = open ? 'date-range-popover' : undefined; + + return ( + <> + + handleClick(e)} + inputProps={{ + readOnly: true, + style: { cursor: 'pointer' }, + }} + /> + + +
+
+ + + + +
+ +
+
+
+ {windowOptions.map(opt => ( + + handleSubmitPresetDates(opt.value)} + > + {opt.name} + + + ))} +
+
+
+ + ); +}; + +export default React.memo(SelectWindow); diff --git a/plugins/opencost/src/components/Subtitle.jsx b/plugins/opencost/src/components/Subtitle.jsx new file mode 100644 index 0000000000..63b3df61ff --- /dev/null +++ b/plugins/opencost/src/components/Subtitle.jsx @@ -0,0 +1,59 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { makeStyles } from '@material-ui/styles'; +import { upperFirst } from 'lodash'; +import Breadcrumbs from '@material-ui/core/Breadcrumbs'; +import NavigateNextIcon from '@material-ui/icons/NavigateNext'; +import Typography from '@material-ui/core/Typography'; +import { toVerboseTimeRange } from '../util'; + +const useStyles = makeStyles({ + root: { + '& > * + *': { + marginTop: 2, + }, + }, + link: { + cursor: 'pointer', + }, +}); + +const Subtitle = ({ report }) => { + const classes = useStyles(); + + const { aggregateBy, window } = report; + + return ( +
+ } + aria-label="breadcrumb" + > + {aggregateBy && aggregateBy.length > 0 ? ( + + {toVerboseTimeRange(window)} by {upperFirst(aggregateBy)} + + ) : ( + {toVerboseTimeRange(window)} + )} + +
+ ); +}; + +export default React.memo(Subtitle); diff --git a/plugins/opencost/src/components/Warnings.jsx b/plugins/opencost/src/components/Warnings.jsx new file mode 100644 index 0000000000..ae1047ebc2 --- /dev/null +++ b/plugins/opencost/src/components/Warnings.jsx @@ -0,0 +1,53 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { makeStyles } from '@material-ui/styles'; +import List from '@material-ui/core/List'; +import ListItem from '@material-ui/core/ListItem'; +import ListItemIcon from '@material-ui/core/ListItemIcon'; +import ListItemText from '@material-ui/core/ListItemText'; +import Paper from '@material-ui/core/Paper'; +import WarningIcon from '@material-ui/icons/Warning'; + +const useStyles = makeStyles({ + root: {}, +}); + +const Warnings = ({ warnings }) => { + const classes = useStyles(); + + if (!warnings || warnings.length === 0) { + return null; + } + + return ( + + + {warnings.map((warn, i) => ( + + + + + + + ))} + + + ); +}; + +export default Warnings; diff --git a/plugins/opencost/src/constants/colors.js b/plugins/opencost/src/constants/colors.js new file mode 100644 index 0000000000..8e0289541b --- /dev/null +++ b/plugins/opencost/src/constants/colors.js @@ -0,0 +1,52 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import blue from '@material-ui/core/colors/blue'; +import brown from '@material-ui/core/colors/brown'; +import cyan from '@material-ui/core/colors/cyan'; +import deepOrange from '@material-ui/core/colors/deepOrange'; +import deepPurple from '@material-ui/core/colors/deepPurple'; +import green from '@material-ui/core/colors/green'; +import grey from '@material-ui/core/colors/grey'; +import indigo from '@material-ui/core/colors/indigo'; +import orange from '@material-ui/core/colors/orange'; +import red from '@material-ui/core/colors/red'; +import teal from '@material-ui/core/colors/teal'; +import yellow from '@material-ui/core/colors/yellow'; + +export const primary = [ + blue[500], + red[500], + green[500], + yellow[500], + cyan[500], + orange[500], + teal[500], + indigo[500], + deepOrange[500], + deepPurple[500], +]; + +export const greyscale = [ + grey[300], + grey[400], + grey[200], + grey[500], + grey[100], + grey[600], +]; + +export const browns = [brown[500]]; diff --git a/plugins/opencost/src/constants/currencyCodes.js b/plugins/opencost/src/constants/currencyCodes.js new file mode 100644 index 0000000000..a325df54d0 --- /dev/null +++ b/plugins/opencost/src/constants/currencyCodes.js @@ -0,0 +1,196 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const currencyCodes = [ + 'AED', + 'AFN', + 'ALL', + 'AMD', + 'ANG', + 'AOA', + 'ARS', + 'AUD', + 'AWG', + 'AZN', + 'BAM', + 'BBD', + 'BDT', + 'BGN', + 'BHD', + 'BIF', + 'BMD', + 'BND', + 'BOB', + 'BOV', + 'BRL', + 'BSD', + 'BTN', + 'BWP', + 'BYR', + 'BZD', + 'CAD', + 'CDF', + 'CHE', + 'CHF', + 'CHW', + 'CLF', + 'CLP', + 'CNY', + 'COP', + 'COU', + 'CRC', + 'CUC', + 'CUP', + 'CVE', + 'CZK', + 'DJF', + 'DKK', + 'DOP', + 'DZD', + 'EGP', + 'ERN', + 'ETB', + 'EUR', + 'FJD', + 'FKP', + 'GBP', + 'GEL', + 'GHS', + 'GIP', + 'GMD', + 'GNF', + 'GTQ', + 'GYD', + 'HKD', + 'HNL', + 'HRK', + 'HTG', + 'HUF', + 'IDR', + 'ILS', + 'INR', + 'IQD', + 'IRR', + 'ISK', + 'JMD', + 'JOD', + 'JPY', + 'KES', + 'KGS', + 'KHR', + 'KMF', + 'KPW', + 'KRW', + 'KWD', + 'KYD', + 'KZT', + 'LAK', + 'LBP', + 'LKR', + 'LRD', + 'LSL', + 'LTL', + 'LVL', + 'LYD', + 'MAD', + 'MDL', + 'MGA', + 'MKD', + 'MMK', + 'MNT', + 'MOP', + 'MRO', + 'MUR', + 'MVR', + 'MWK', + 'MXN', + 'MXV', + 'MYR', + 'MZN', + 'NAD', + 'NGN', + 'NIO', + 'NOK', + 'NPR', + 'NZD', + 'OMR', + 'PAB', + 'PEN', + 'PGK', + 'PHP', + 'PKR', + 'PLN', + 'PYG', + 'QAR', + 'RON', + 'RSD', + 'RUB', + 'RWF', + 'SAR', + 'SBD', + 'SCR', + 'SDG', + 'SEK', + 'SGD', + 'SHP', + 'SLL', + 'SOS', + 'SRD', + 'SSP', + 'STD', + 'SYP', + 'SZL', + 'THB', + 'TJS', + 'TMT', + 'TND', + 'TOP', + 'TRY', + 'TTD', + 'TWD', + 'TZS', + 'UAH', + 'UGX', + 'USD', + 'USN', + 'USS', + 'UYI', + 'UYU', + 'UZS', + 'VEF', + 'VND', + 'VUV', + 'WST', + 'XAF', + 'XAG', + 'XAU', + 'XBA', + 'XBB', + 'XBC', + 'XBD', + 'XCD', + 'XDR', + 'XFU', + 'XOF', + 'XPD', + 'XPF', + 'XPT', + 'XTS', + 'XXX', + 'YER', + 'ZAR', + 'ZMW', +]; diff --git a/plugins/opencost/src/images/pig.png b/plugins/opencost/src/images/pig.png new file mode 100644 index 0000000000..5de5409e87 Binary files /dev/null and b/plugins/opencost/src/images/pig.png differ diff --git a/plugins/opencost/src/index.ts b/plugins/opencost/src/index.ts new file mode 100644 index 0000000000..30153e0c32 --- /dev/null +++ b/plugins/opencost/src/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * A Backstage plugin that integrates with OpenCost + * + * @packageDocumentation + */ + +export { openCostPlugin, OpenCostPage } from './plugin'; diff --git a/plugins/opencost/src/plugin.test.ts b/plugins/opencost/src/plugin.test.ts new file mode 100644 index 0000000000..6d6d20c603 --- /dev/null +++ b/plugins/opencost/src/plugin.test.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { openCostPlugin } from './plugin'; + +describe('opencost', () => { + it('should export plugin', () => { + expect(openCostPlugin).toBeDefined(); + }); +}); diff --git a/plugins/opencost/src/plugin.ts b/plugins/opencost/src/plugin.ts new file mode 100644 index 0000000000..2725cf3dde --- /dev/null +++ b/plugins/opencost/src/plugin.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + createPlugin, + createRoutableExtension, +} from '@backstage/core-plugin-api'; + +import { rootRouteRef } from './routes'; + +/** @public */ +export const openCostPlugin = createPlugin({ + id: 'opencost', + routes: { + root: rootRouteRef, + }, +}); + +/** @public */ +export const OpenCostPage = openCostPlugin.provide( + createRoutableExtension({ + name: 'OpenCostPage', + component: () => + import('./components/OpenCostPage').then(m => m.OpenCostPage), + mountPoint: rootRouteRef, + }), +); diff --git a/plugins/opencost/src/routes.ts b/plugins/opencost/src/routes.ts new file mode 100644 index 0000000000..5f1f9e4527 --- /dev/null +++ b/plugins/opencost/src/routes.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createRouteRef } from '@backstage/core-plugin-api'; + +export const rootRouteRef = createRouteRef({ + id: 'opencost', +}); diff --git a/plugins/opencost/src/services/allocation.js b/plugins/opencost/src/services/allocation.js new file mode 100644 index 0000000000..e7fba8feae --- /dev/null +++ b/plugins/opencost/src/services/allocation.js @@ -0,0 +1,36 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import axios from 'axios'; + +class AllocationService { + async fetchAllocation(baseUrl, win, aggregate, options) { + const { accumulate } = options; + const params = { + window: win, + aggregate: aggregate, + step: '1d', + }; + if (typeof accumulate === 'boolean') { + params.accumulate = accumulate; + } + + const result = await axios.get(`${baseUrl}/allocation/compute`, { params }); + + return result.data; + } +} + +export default new AllocationService(); diff --git a/plugins/opencost/src/util.js b/plugins/opencost/src/util.js new file mode 100644 index 0000000000..5854718a55 --- /dev/null +++ b/plugins/opencost/src/util.js @@ -0,0 +1,371 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { forEach, get, round } from 'lodash'; + +// rangeToCumulative takes an AllocationSetRange (type: array[AllocationSet]) +// and accumulates the values into a single AllocationSet (type: object) +export function rangeToCumulative(allocationSetRange, aggregateBy) { + if (allocationSetRange.length === 0) { + return null; + } + + const result = {}; + + forEach(allocationSetRange, allocSet => { + forEach(allocSet, alloc => { + if (result[alloc.name] === undefined) { + const hrs = get(alloc, 'minutes', 0) / 60.0; + + result[alloc.name] = { + name: alloc.name, + [aggregateBy]: alloc.name, + cpuCost: get(alloc, 'cpuCost', 0), + gpuCost: get(alloc, 'gpuCost', 0), + ramCost: get(alloc, 'ramCost', 0), + pvCost: get(alloc, 'pvCost', 0), + networkCost: get(alloc, 'networkCost', 0), + sharedCost: get(alloc, 'sharedCost', 0), + externalCost: get(alloc, 'externalCost', 0), + totalCost: get(alloc, 'totalCost', 0), + cpuUseCoreHrs: get(alloc, 'cpuCoreUsageAverage', 0) * hrs, + cpuReqCoreHrs: get(alloc, 'cpuCoreRequestAverage', 0) * hrs, + ramUseByteHrs: get(alloc, 'ramByteUsageAverage', 0) * hrs, + ramReqByteHrs: get(alloc, 'ramByteRequestAverage', 0) * hrs, + cpuEfficiency: get(alloc, 'cpuEfficiency', 0), + ramEfficiency: get(alloc, 'ramEfficiency', 0), + totalEfficiency: get(alloc, 'totalEfficiency', 0), + }; + } else { + const hrs = get(alloc, 'minutes', 0) / 60.0; + + result[alloc.name].cpuCost += get(alloc, 'cpuCost', 0); + result[alloc.name].gpuCost += get(alloc, 'gpuCost', 0); + result[alloc.name].ramCost += get(alloc, 'ramCost', 0); + result[alloc.name].pvCost += get(alloc, 'pvCost', 0); + result[alloc.name].networkCost += get(alloc, 'networkCost', 0); + result[alloc.name].sharedCost += get(alloc, 'sharedCost', 0); + result[alloc.name].externalCost += get(alloc, 'externalCost', 0); + result[alloc.name].totalCost += get(alloc, 'totalCost', 0); + result[alloc.name].cpuUseCoreHrs += + get(alloc, 'cpuCoreUsageAverage', 0) * hrs; + result[alloc.name].cpuReqCoreHrs += + get(alloc, 'cpuCoreRequestAverage', 0) * hrs; + result[alloc.name].ramUseByteHrs += + get(alloc, 'ramByteUsageAverage', 0) * hrs; + result[alloc.name].ramReqByteHrs += + get(alloc, 'ramByteRequestAverage', 0) * hrs; + } + }); + }); + + // If the range is of length > 1 (i.e. it is not just a single set) then + // compute efficiency for each result after accumulating. + if (allocationSetRange.length > 1) { + forEach(result, (alloc, name) => { + // If we can't compute total efficiency, it defaults to 0.0 + let totalEfficiency = 0.0; + + // CPU efficiency is defined as (usage/request). If request == 0.0 but + // usage > 0, then efficiency gets set to 1.0. + let cpuEfficiency = 0.0; + if (alloc.cpuReqCoreHrs > 0) { + cpuEfficiency = alloc.cpuUseCoreHrs / alloc.cpuReqCoreHrs; + } else if (alloc.cpuUseCoreHrs > 0) { + cpuEfficiency = 1.0; + } + + // RAM efficiency is defined as (usage/request). If request == 0.0 but + // usage > 0, then efficiency gets set to 1.0. + let ramEfficiency = 0.0; + if (alloc.ramReqByteHrs > 0) { + ramEfficiency = alloc.ramUseByteHrs / alloc.ramReqByteHrs; + } else if (alloc.ramUseByteHrs > 0) { + ramEfficiency = 1.0; + } + + // Compute efficiency as the cost-weighted average of CPU and RAM + // efficiency + if (alloc.cpuCost + alloc.ramCost > 0.0) { + totalEfficiency = + (alloc.cpuCost * cpuEfficiency + alloc.ramCost * ramEfficiency) / + (alloc.cpuCost + alloc.ramCost); + } + + result[name].cpuEfficiency = cpuEfficiency; + result[name].ramEfficiency = ramEfficiency; + result[name].totalEfficiency = totalEfficiency; + }); + } + + return result; +} + +// cumulativeToTotals adds each entry in the given AllocationSet (type: object) +// and returns a single Allocation (type: object) representing the totals +export function cumulativeToTotals(allocationSet) { + const totals = { + name: 'Totals', + cpuCost: 0, + gpuCost: 0, + ramCost: 0, + pvCost: 0, + networkCost: 0, + sharedCost: 0, + externalCost: 0, + totalCost: 0, + cpuEfficiency: 0, + ramEfficiency: 0, + totalEfficiency: 0, + }; + + // Use these for computing efficiency. As such, idle will not factor into + // these numbers, including CPU and RAM cost. + let cpuReqCoreHrs = 0; + let cpuUseCoreHrs = 0; + let ramReqByteHrs = 0; + let ramUseByteHrs = 0; + let cpuCost = 0; + let ramCost = 0; + + forEach(allocationSet, (alloc, name) => { + // Accumulate efficiency-related fields + if (name !== '__idle__') { + cpuReqCoreHrs += get(alloc, 'cpuReqCoreHrs', 0.0); + cpuUseCoreHrs += get(alloc, 'cpuUseCoreHrs', 0.0); + ramReqByteHrs += get(alloc, 'ramReqByteHrs', 0.0); + ramUseByteHrs += get(alloc, 'ramUseByteHrs', 0.0); + cpuCost += get(alloc, 'cpuCost', 0.0); + ramCost += get(alloc, 'ramCost', 0.0); + } + + // Sum cumulative fields + totals.cpuCost += get(alloc, 'cpuCost', 0); + totals.gpuCost += get(alloc, 'gpuCost', 0); + totals.ramCost += get(alloc, 'ramCost', 0); + totals.pvCost += get(alloc, 'pvCost', 0); + totals.networkCost += get(alloc, 'networkCost', 0); + totals.sharedCost += get(alloc, 'sharedCost', 0); + totals.externalCost += get(alloc, 'externalCost', 0); + totals.totalCost += get(alloc, 'totalCost', 0); + }); + + // Compute efficiency + if (cpuReqCoreHrs > 0) { + totals.cpuEfficiency = cpuUseCoreHrs / cpuReqCoreHrs; + } else if (cpuUseCoreHrs > 0) { + totals.cpuEfficiency = 1.0; + } + + if (ramReqByteHrs > 0) { + totals.ramEfficiency = ramUseByteHrs / ramReqByteHrs; + } else if (ramUseByteHrs > 0) { + totals.ramEfficiency = 1.0; + } + + if (cpuCost + ramCost > 0) { + totals.totalEfficiency = + (cpuCost * totals.cpuEfficiency + ramCost * totals.ramEfficiency) / + (cpuCost + ramCost); + } + + totals.cpuReqCoreHrs = cpuReqCoreHrs; + totals.cpuUseCoreHrs = cpuUseCoreHrs; + totals.ramReqByteHrs = ramReqByteHrs; + totals.ramUseByteHrs = ramUseByteHrs; + + return totals; +} + +export function toVerboseTimeRange(window) { + const months = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ]; + + const start = new Date(); + start.setUTCHours(0, 0, 0, 0); + + const end = new Date(); + end.setUTCHours(0, 0, 0, 0); + + switch (window) { + case 'today': + return `${start.getUTCDate()} ${ + months[start.getUTCMonth()] + } ${start.getUTCFullYear()}`; + case 'yesterday': + start.setUTCDate(start.getUTCDate() - 1); + return `${start.getUTCDate()} ${ + months[start.getUTCMonth()] + } ${start.getUTCFullYear()}`; + case 'week': + start.setUTCDate(start.getUTCDate() - start.getUTCDay()); + return `${start.getUTCDate()} ${ + months[start.getUTCMonth()] + } ${start.getUTCFullYear()} until now`; + case 'month': + start.setUTCDate(1); + return `${start.getUTCDate()} ${ + months[start.getUTCMonth()] + } ${start.getUTCFullYear()} until now`; + case 'lastweek': + start.setUTCDate(start.getUTCDate() - (start.getUTCDay() + 7)); + end.setUTCDate(end.getUTCDate() - (end.getUTCDay() + 1)); + return `${start.getUTCDate()} ${ + months[start.getUTCMonth()] + } ${start.getUTCFullYear()} through ${end.getUTCDate()} ${ + months[end.getUTCMonth()] + } ${end.getUTCFullYear()}`; + case 'lastmonth': + end.setUTCDate(1); + end.setUTCDate(end.getUTCDate() - 1); + start.setUTCDate(1); + start.setUTCDate(start.getUTCDate() - 1); + start.setUTCDate(1); + return `${start.getUTCDate()} ${ + months[start.getUTCMonth()] + } ${start.getUTCFullYear()} through ${end.getUTCDate()} ${ + months[end.getUTCMonth()] + } ${end.getUTCFullYear()}`; + case '6d': + start.setUTCDate(start.getUTCDate() - 6); + return `${start.getUTCDate()} ${ + months[start.getUTCMonth()] + } ${start.getUTCFullYear()} through now`; + case '29d': + start.setUTCDate(start.getUTCDate() - 29); + return `${start.getUTCDate()} ${ + months[start.getUTCMonth()] + } ${start.getUTCFullYear()} through now`; + case '59d': + start.setUTCDate(start.getUTCDate() - 59); + return `${start.getUTCDate()} ${ + months[start.getUTCMonth()] + } ${start.getUTCFullYear()} through now`; + case '89d': + start.setUTCDate(start.getUTCDate() - 89); + return `${start.getUTCDate()} ${ + months[start.getUTCMonth()] + } ${start.getUTCFullYear()} through now`; + // no default + } + + const splitDates = window.split(','); + if (checkCustomWindow(window) && splitDates.length > 1) { + const s = splitDates[0].split(/\D+/).slice(0, 3); + const e = splitDates[1].split(/\D+/).slice(0, 3); + if (s.length === 3 && e.length === 3) { + start.setUTCFullYear(s[0], s[1] - 1, s[2]); + end.setUTCFullYear(e[0], e[1] - 1, e[2]); + if (start === end) { + return `${start.getUTCDate()} ${ + months[start.getUTCMonth()] + } ${start.getUTCFullYear()}`; + } + return `${start.getUTCDate()} ${ + months[start.getUTCMonth()] + } ${start.getUTCFullYear()} through ${end.getUTCDate()} ${ + months[end.getUTCMonth()] + } ${end.getUTCFullYear()}`; + } + } + return ''; +} + +export function bytesToString(bytes) { + const ei = Math.pow(1024, 6); + if (bytes >= ei) { + return `${round(bytes / ei, 1)} EiB`; + } + const pi = Math.pow(1024, 5); + if (bytes >= pi) { + return `${round(bytes / pi, 1)} PiB`; + } + const ti = Math.pow(1024, 4); + if (bytes >= ti) { + return `${round(bytes / ti, 1)} TiB`; + } + const gi = Math.pow(1024, 3); + if (bytes >= gi) { + return `${round(bytes / gi, 1)} GiB`; + } + const mi = Math.pow(1024, 2); + if (bytes >= mi) { + return `${round(bytes / mi, 1)} MiB`; + } + const ki = Math.pow(1024, 1); + if (bytes >= ki) { + return `${round(bytes / ki, 1)} KiB`; + } + + return `${round(bytes, 1)} B`; +} + +const currencyLocale = 'en-US'; + +export function toCurrency(amount, currency, precision) { + if (typeof amount !== 'number') { + Console.warn( + `Tried to convert "${amount}" to currency, but it is not a number`, + ); + return ''; + } + + let ctype = currency; + + if (currency === undefined || currency === '') { + ctype = 'USD'; + } + + const opts = { + style: 'currency', + currency: ctype, + }; + + if (typeof precision === 'number') { + opts.minimumFractionDigits = precision; + opts.maximumFractionDigits = precision; + } + + return amount.toLocaleString(currencyLocale, opts); +} + +export function checkCustomWindow(window) { + // Example ISO interval string: 2020-12-02T00:00:00Z,2020-12-03T23:59:59Z + const customDateRegex = + /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z,\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z/; + return customDateRegex.test(window); +} + +export default { + rangeToCumulative, + cumulativeToTotals, + toVerboseTimeRange, + bytesToString, + toCurrency, + checkCustomWindow, +}; diff --git a/yarn.lock b/yarn.lock index 451a587cc6..e2f47d2d8a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7529,6 +7529,39 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-opencost@workspace:plugins/opencost": + version: 0.0.0-use.local + resolution: "@backstage/plugin-opencost@workspace:plugins/opencost" + dependencies: + "@backstage/cli": "workspace:^" + "@backstage/core-app-api": "workspace:^" + "@backstage/core-components": "workspace:^" + "@backstage/core-plugin-api": "workspace:^" + "@backstage/dev-utils": "workspace:^" + "@backstage/test-utils": "workspace:^" + "@backstage/theme": "workspace:^" + "@date-io/luxon": 1.x + "@material-ui/core": ^4.9.13 + "@material-ui/icons": ^4.9.1 + "@material-ui/lab": ^4.0.0-alpha.60 + "@material-ui/pickers": ^3.3.10 + "@material-ui/styles": ^4.11.5 + "@testing-library/jest-dom": ^5.10.1 + "@testing-library/react": ^12.1.3 + "@testing-library/user-event": ^14.0.0 + axios: ^1.4.0 + cross-fetch: ^3.1.5 + date-fns: ^2.30.0 + lodash: ^4.17.21 + msw: ^1.0.0 + react-use: ^17.2.4 + recharts: ^2.5.0 + peerDependencies: + react: ^16.13.1 || ^17.0.0 + react-router-dom: "*" + languageName: unknown + linkType: soft + "@backstage/plugin-org-react@workspace:plugins/org-react": version: 0.0.0-use.local resolution: "@backstage/plugin-org-react@workspace:plugins/org-react"