Merge pull request #18383 from mattray/opencost

New OpenCost plugin
This commit is contained in:
Fredrik Adelöw
2023-08-16 10:34:56 +02:00
committed by GitHub
34 changed files with 3044 additions and 0 deletions
+5
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
+79
View File
@@ -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
<FlatRoutes>
<Route path="/opencost" element={<OpenCostPage />} />
</FlatRoutes>
```
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<{}>) => (
<SidebarPage>
<Sidebar>
...
<SidebarItem icon={MoneyIcon} to="opencost" text="OpenCost" />
...
</Sidebar>
</SidebarPage>
);
```
## 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)
+22
View File
@@ -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
/// <reference types="react" />
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<undefined>;
},
{},
{}
>;
```
+30
View File
@@ -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;
};
}
+27
View File
@@ -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: <OpenCostPage />,
title: 'Root Page',
path: '/opencost',
})
.render();
+60
View File
@@ -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"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

@@ -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 (
<div className={classes.tooltip}>
<p
className={classes.tooltipLineItem}
style={{ color: '#000000' }}
>{`Total: ${toCurrency(total, currency)}`}</p>
{reverse(payload).map((item, i) => (
<p
key={i}
className={classes.tooltipLineItem}
style={{ color: item.fill }}
>{`${item.name}: ${toCurrency(item.value, currency)}`}</p>
))}
</div>
);
}
return null;
};
return (
<ResponsiveContainer width="100%" height={height}>
<BarChart
data={barData}
margin={{ top: 30, right: 30, left: 30, bottom: 12 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="start" />
<YAxis />
<Tooltip content={<CustomTooltip />} />
{barLabels.map((barLabel, i) => (
<Bar
key={i}
dataKey={barLabel.dataKey}
stackId="a"
fill={barLabel.fill}
/>
))}
</BarChart>
</ResponsiveContainer>
);
};
export default RangeChart;
@@ -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 (
<text
x={x}
y={y}
fill={fill}
textAnchor={x > cx ? 'start' : 'end'}
dominantBaseline="central"
>
{`${name}: ${toCurrency(value, currency)} (${(percent * 100).toFixed(
1,
)}%)`}
</text>
);
};
return (
<ResponsiveContainer width="100%" height={height}>
<PieChart>
<Pie
data={pieData}
dataKey="value"
nameKey="name"
label={renderLabel}
labelLine
// niko: if tooltips error, try disabling animation
// isAnimationActive={false}
animationDuration={400}
cy="90%"
outerRadius="140%"
innerRadius="60%"
startAngle={180}
endAngle={0}
>
{pieData.map((datum, i) => (
<Cell key={i} fill={datum.fill} />
))}
</Pie>
</PieChart>
</ResponsiveContainer>
);
};
export default SummaryChart;
@@ -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 <Typography variant="body2">No data</Typography>;
}
if (allocationRange.length === 1) {
const datum = top(n, alloc => alloc.totalCost)(allocationRange[0]);
return (
<SummaryChart
top={datum.top}
other={datum.other}
idle={datum.idle}
currency={currency}
height={height}
/>
);
}
const data = top(n, alloc => alloc.totalCost)(allocationRange);
return <RangeChart data={data} currency={currency} height={height} />;
};
export default React.memo(AllocationChart);
@@ -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 (
<Typography variant="body2" className={classes.noResults}>
No results
</Typography>
);
}
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 (
<div id="report">
<AllocationChart
allocationRange={allocationData}
currency={currency}
n={10}
height={300}
/>
<TableContainer>
<Table>
<TableHead>
<TableRow>
{headCells.map(cell => (
<TableCell
key={cell.id}
colSpan={cell.colspan}
align={cell.numeric ? 'right' : 'left'}
sortDirection={orderBy === cell.id ? order : false}
style={{ width: cell.width }}
>
<TableSortLabel
active={orderBy === cell.id}
direction={orderBy === cell.id ? order : 'asc'}
onClick={createSortHandler(cell.id)}
>
{cell.label}
</TableSortLabel>
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
<TableRow>
{headCells.map(cell => {
return (
<TableCell
key={cell.id}
colSpan={cell.colspan}
align={cell.numeric ? 'right' : 'left'}
style={{ fontWeight: 500 }}
>
{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]}
</TableCell>
);
})}
</TableRow>
{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 (
<TableRow key={key}>
<TableCell align="left">{row.name}</TableCell>
<TableCell align="right">
{toCurrency(row.cpuCost, currency)}
</TableCell>
<TableCell align="right">
{toCurrency(row.ramCost, currency)}
</TableCell>
<TableCell align="right">
{toCurrency(row.pvCost, currency)}
</TableCell>
{isIdle ? (
<TableCell align="right">&mdash;</TableCell>
) : (
<TableCell align="right">{efficiency}%</TableCell>
)}
<TableCell align="right">
{toCurrency(row.totalCost, currency)}
</TableCell>
</TableRow>
);
}
return (
<TableRow key={key}>
<TableCell align="left">{row.name}</TableCell>
<TableCell align="right">
{toCurrency(row.cpuCost, currency)}
</TableCell>
<TableCell align="right">
{toCurrency(row.ramCost, currency)}
</TableCell>
<TableCell align="right">
{toCurrency(row.pvCost, currency)}
</TableCell>
<TableCell align="right">{efficiency}%</TableCell>
<TableCell align="right">
{toCurrency(row.totalCost, currency)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={numData}
rowsPerPage={rowsPerPage}
rowsPerPageOptions={[10, 25, 50]}
page={Math.min(page, lastPage)}
onChangePage={handleChangePage}
onChangeRowsPerPage={handleChangeRowsPerPage}
/>
</div>
);
};
export default React.memo(AllocationReport);
@@ -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 (
<Tooltip title="Download CSV">
<IconButton onClick={downloadReport}>
<ExportIcon />
</IconButton>
</Tooltip>
);
};
export default React.memo(DownloadControl);
@@ -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 (
<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="accumulate-label">Resolution</InputLabel>
<Select
id="accumulate"
value={accumulate}
onChange={e => setAccumulate(e.target.value)}
>
{accumulateOptions.map(opt => (
<MenuItem key={opt.value} value={opt.value}>
{opt.name}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl className={classes.formControl}>
<InputLabel id="currency-label">Currency</InputLabel>
<Select
id="currency"
value={currency}
onChange={e => setCurrency(e.target.value)}
>
{currencyOptions?.map(currencyVal => (
<MenuItem key={currencyVal} value={currencyVal}>
{currencyVal}
</MenuItem>
))}
</Select>
</FormControl>
</div>
);
}
export default React.memo(EditControl);
@@ -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 (
<div>
<EditControl
windowOptions={windowOptions}
window={window}
setWindow={setWindow}
aggregationOptions={aggregationOptions}
aggregateBy={aggregateBy}
setAggregateBy={setAggregateBy}
accumulateOptions={accumulateOptions}
accumulate={accumulate}
setAccumulate={setAccumulate}
currency={currency}
currencyOptions={currencyOptions}
setCurrency={setCurrency}
/>
<DownloadControl cumulativeData={cumulativeData} title={title} />
</div>
);
};
export default React.memo(Controls);
+287
View File
@@ -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 (
<div style={{ display: 'flex', justifyContent: 'center' }}>
<div style={{ paddingTop: 100, paddingBottom: 100 }}>
<CircularProgress />
</div>
</div>
);
}
return (
<div>
{!loading && errors.length > 0 && (
<div style={{ marginBottom: 20 }}>
<Warnings warnings={errors} />
</div>
)}
<List>
{cluster && (
<ListItem>
<ListItemIcon>
<ClusterIcon />
</ListItemIcon>
<ListItemText primary={cluster} />
</ListItem>
)}
{node && (
<ListItem>
<ListItemIcon>
<NodeIcon />
</ListItemIcon>
<ListItemText primary={node} />
</ListItem>
)}
</List>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell align="left" component="th" scope="row" width={200}>
Container
</TableCell>
<TableCell align="right" component="th" scope="row">
Hours
</TableCell>
<TableCell align="right" component="th" scope="row">
CPU
</TableCell>
<TableCell align="right" component="th" scope="row">
$/(CPU*Hr)
</TableCell>
<TableCell align="right" component="th" scope="row">
CPU cost
</TableCell>
<TableCell align="right" component="th" scope="row">
RAM
</TableCell>
<TableCell align="right" component="th" scope="row">
$/(GiB*Hr)
</TableCell>
<TableCell align="right" component="th" scope="row">
RAM cost
</TableCell>
<TableCell align="right" component="th" scope="row">
Total cost
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{rows.map((row, i) => (
<TableRow key={i} hover>
<TableCell align="left" component="th" scope="row" width={200}>
{row.container}
</TableCell>
<TableCell align="right" component="th" scope="row">
{row.hours}
</TableCell>
<TableCell align="right" component="th" scope="row">
{row.cpu}
</TableCell>
<TableCell align="right" component="th" scope="row">
{toCurrency(row.cpuCostPerCoreHr, currency, 5)}
</TableCell>
<TableCell align="right" component="th" scope="row">
{toCurrency(row.cpuCost, currency, 3)}
</TableCell>
<TableCell align="right" component="th" scope="row">
{bytesToString(row.ram)}
</TableCell>
<TableCell align="right" component="th" scope="row">
{toCurrency(row.ramCostPerGiBHr, currency, 5)}
</TableCell>
<TableCell align="right" component="th" scope="row">
{toCurrency(row.ramCost, currency, 3)}
</TableCell>
<TableCell align="right" component="th" scope="row">
{toCurrency(row.totalCost, currency, 3)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</div>
);
};
export default memo(Details);
@@ -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 = () => (
<Page themeId="tool">
<Header
title="OpenCost"
subtitle="Open source Kubernetes cloud cost monitoring"
>
<a href="https://opencost.io">
<img
width={68}
height={64}
src={require('../../images/pig.png')}
alt="OpenCost"
/>
</a>
</Header>
<Content>
<Grid container spacing={3} direction="column">
<Grid item>
<OpenCostReport />
</Grid>
</Grid>
</Content>
</Page>
);
@@ -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';
@@ -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 (
<Page active="reports.html">
{!loading && errors.length > 0 && (
<div style={{ marginBottom: 20 }}>
<Warnings warnings={errors} />
</div>
)}
{init && (
<Paper id="report">
<div className={classes.reportHeader}>
<div className={classes.titles}>
<Typography variant="h5">{title}</Typography>
<Subtitle report={{ window, aggregateBy, accumulate }} />
</div>
<IconButton aria-label="refresh" onClick={() => setFetch(true)}>
<RefreshIcon />
</IconButton>
<Controls
windowOptions={windowOptions}
window={window}
// @ts-ignore: implicitly has an 'any' type
setWindow={win => {
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()}`,
});
}}
/>
</div>
{loading && (
<div style={{ display: 'flex', justifyContent: 'center' }}>
<div style={{ paddingTop: 100, paddingBottom: 100 }}>
<CircularProgress />
</div>
</div>
)}
{!loading && (
<AllocationReport
allocationData={allocationData}
cumulativeData={cumulativeData}
totalData={totalData}
currency={currency}
/>
)}
</Paper>
)}
</Page>
);
};
@@ -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';
+47
View File
@@ -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 (
<div className={classes.flexGrow}>
<div className={classes.wrapper}>
<div className={classes.flexGrow}>{props.children}</div>
</div>
</div>
);
};
export default Page;
@@ -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 (
<>
<FormControl className={classes.formControl}>
<TextField
id="filled-read-only-input"
label="Date Range"
value={get(find(windowOptions, { value: window }), 'name', 'Custom')}
onClick={e => handleClick(e)}
inputProps={{
readOnly: true,
style: { cursor: 'pointer' },
}}
/>
</FormControl>
<Popover
id={id}
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'center',
}}
>
<div className={classes.dateContainer}>
<div className={classes.dateContainerColumn}>
<MuiPickersUtilsProvider utils={LuxonUtils}>
<KeyboardDatePicker
style={{ width: '144px' }}
autoOk
disableToolbar
variant="inline"
format="MM/dd/yyyy"
margin="normal"
id="date-picker-start"
label="Start Date"
value={startDate}
maxDate={new Date()}
maxDateMessage="Date should not be after today."
onChange={handleStartDateChange}
KeyboardButtonProps={{
'aria-label': 'change date',
}}
/>
<KeyboardDatePicker
style={{ width: '144px' }}
autoOk
disableToolbar
variant="inline"
format="MM/dd/yyyy"
margin="normal"
id="date-picker-end"
label="End Date"
value={endDate}
maxDate={new Date()}
maxDateMessage="Date should not be after today."
onChange={handleEndDateChange}
KeyboardButtonProps={{
'aria-label': 'change date',
}}
/>
</MuiPickersUtilsProvider>
<div>
<Button
style={{ marginTop: 16 }}
variant="contained"
color="default"
onClick={handleSubmitCustomDates}
>
Apply
</Button>
</div>
</div>
<div
className={classes.dateContainerColumn}
style={{ paddingTop: 12, marginLeft: 18 }}
>
{windowOptions.map(opt => (
<Typography key={opt.value}>
<Link
style={{ cursor: 'pointer' }}
key={opt.value}
value={opt.value}
onClick={() => handleSubmitPresetDates(opt.value)}
>
{opt.name}
</Link>
</Typography>
))}
</div>
</div>
</Popover>
</>
);
};
export default React.memo(SelectWindow);
@@ -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 (
<div className={classes.root}>
<Breadcrumbs
separator={<NavigateNextIcon fontSize="small" />}
aria-label="breadcrumb"
>
{aggregateBy && aggregateBy.length > 0 ? (
<Typography>
{toVerboseTimeRange(window)} by {upperFirst(aggregateBy)}
</Typography>
) : (
<Typography>{toVerboseTimeRange(window)}</Typography>
)}
</Breadcrumbs>
</div>
);
};
export default React.memo(Subtitle);
@@ -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 (
<Paper className={classes.root}>
<List>
{warnings.map((warn, i) => (
<ListItem key={i}>
<ListItemIcon>
<WarningIcon />
</ListItemIcon>
<ListItemText primary={warn.primary} secondary={warn.secondary} />
</ListItem>
))}
</List>
</Paper>
);
};
export default Warnings;
+52
View File
@@ -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]];
@@ -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',
];
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

+23
View File
@@ -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';
+23
View File
@@ -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();
});
});
+40
View File
@@ -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,
}),
);
+21
View File
@@ -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',
});
@@ -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();
+371
View File
@@ -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,
};
+33
View File
@@ -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"