@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
|
||||
@@ -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
|
||||
|
||||

|
||||
@@ -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>;
|
||||
},
|
||||
{},
|
||||
{}
|
||||
>;
|
||||
```
|
||||
Vendored
+30
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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">—</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);
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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 |
@@ -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';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user