Working OpenCost Backstage plugin.

Needs cleanup to make the git commit hooks happy

Signed-off-by: Matt Ray <github@mattray.dev>
This commit is contained in:
Matt Ray
2023-06-09 19:05:34 +10:00
committed by Fredrik Adelöw
parent 6b3ea5fbf5
commit 59dc379e35
25 changed files with 1943 additions and 182 deletions
+14 -24
View File
@@ -2,34 +2,24 @@
Welcome to the OpenCost plugin!
_This plugin was created through the Backstage CLI_
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.
## Getting started
Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/opencost](http://localhost:3000/opencost).
You can also serve the plugin in isolation by running `yarn start` in the plugin directory.
This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads.
It is only meant for local development, and the setup for it can be found inside the [/dev](./dev) directory.
## Installation
## IDEAS
## Configuration
opencost logo on side
## TODO
### variables for:
* Document installation and configuration
* More testing
* Use the OpenCost mascot for the sidebar logo
* Use the Backstage proxy to communicate with the OpenCost API
* 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
* Fork(?) to support Kubecost, which could provide Alerts and Recommendations, similar to the Cost Explorer plugin
* clean up deprecation warnings and upgrade to all the latest React components
opencost URL
days of data (14d default)
costs warning lines
top graph:
pick with specific query pattern
dates adjustable
table underneath
TrendLines
Tabs on different namespaces/etc.?
get currencies from cost-insights
![Screenshot](screenshot.png)
+26
View File
@@ -0,0 +1,26 @@
/*
* 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 */
openCost: {
/**
* The base URL for the OpenCost installation.
* @visibility frontend
*/
baseUrl: string;
};
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

@@ -0,0 +1,149 @@
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) {
let 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
}
}
}
let labels = []
for (const key in keyToFill) {
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) {
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) {
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) {
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) {
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,96 @@
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) {
let slices = []
for (const i in top) {
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) {
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) {
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
let y = cy + radius * Math.sin(-midAngle * RADIAN)
// y -= Math.min(Math.abs(2 / Math.cos(-midAngle * RADIAN)), 8)
if (percent < 0.02) {
return
}
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,81 @@
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
// sum allocationSet to single allocation
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,200 @@
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 createSortHandler = (property) => (event) => handleRequestSort(event, property)
const handleRequestSort = (event, property) => {
const isDesc = orderBy === property && order === 'desc'
setOrder(isDesc ? 'asc' : 'desc')
setOrderBy(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"
}
let isIdle = row.name.indexOf("__idle__") >= 0
let isUnallocated = row.name.indexOf("__unallocated__") >= 0
let isUnmounted = row.name.indexOf("Unmounted PVs") >= 0
// Replace "efficiency" with Inf if there is usage w/o request
let efficiency = round(row.totalEfficiency*100, 1)
if (row.totalEfficiency == 1.0 && row.cpuReqCoreHrs == 0 && row.ramReqByteHrs == 0) {
efficiency = "Inf"
}
// Do not allow drill-down for idle and unallocated rows
if (isIdle || isUnallocated || isUnmounted) {
return (
<TableRow key={key}>
<TableCell align="left">{row.name}</TableCell>
<TableCell align="right">{toCurrency(row.cpuCost, currency)}</TableCell>
<TableCell align="right">{toCurrency(row.ramCost, currency)}</TableCell>
<TableCell align="right">{toCurrency(row.pvCost, currency)}</TableCell>
{isIdle ? (
<TableCell align="right">&mdash;</TableCell>
) : (
<TableCell align="right">{efficiency}%</TableCell>
)}
<TableCell align="right">{toCurrency(row.totalCost, currency)}</TableCell>
</TableRow>
)
}
return (
<TableRow key={key}>
<TableCell align="left">{row.name}</TableCell>
<TableCell align="right">{toCurrency(row.cpuCost, currency)}</TableCell>
<TableCell align="right">{toCurrency(row.ramCost, currency)}</TableCell>
<TableCell align="right">{toCurrency(row.pvCost, currency)}</TableCell>
<TableCell align="right">{efficiency}%</TableCell>
<TableCell align="right">{toCurrency(row.totalCost, currency)}</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={numData}
rowsPerPage={rowsPerPage}
rowsPerPageOptions={[10, 25, 50]}
page={Math.min(page, lastPage)}
onChangePage={handleChangePage}
onChangeRowsPerPage={handleChangeRowsPerPage}
/>
</div>
)
}
export default React.memo(AllocationReport)
@@ -0,0 +1,89 @@
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) => {
let 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.toLowerCase().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,74 @@
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((currency) => (
<MenuItem key={currency} value={currency}>
{currency}
</MenuItem>
))}
</Select>
</FormControl>
</div>
);
}
export default React.memo(EditControl);
@@ -0,0 +1,46 @@
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)
+227
View File
@@ -0,0 +1,227 @@
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()
}
}, [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) {
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)
@@ -1,40 +0,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 from 'react';
import { render, screen } from '@testing-library/react';
import { OpenCostFetchComponent } from './OpenCostFetchComponent';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { setupRequestMockHandlers } from '@backstage/test-utils';
describe('OpenCostFetchComponent', () => {
const server = setupServer();
// Enable sane handlers for network requests
setupRequestMockHandlers(server);
// setup mock response
beforeEach(() => {
server.use(
rest.get('https://randomuser.me/*', (_, res, ctx) =>
res(ctx.status(200), ctx.delay(2000), ctx.json({})),
),
);
});
it('should render', async () => {
await render(<OpenCostFetchComponent />);
expect(await screen.findByTestId('progress')).toBeInTheDocument();
});
});
@@ -1,111 +0,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 from 'react';
import { makeStyles } from '@material-ui/core/styles';
import {
Table,
TableColumn,
Progress,
ResponseErrorPanel,
} from '@backstage/core-components';
import { fetchApiRef, useApi } from '@backstage/core-plugin-api';
import useAsync from 'react-use/lib/useAsync';
const useStyles = makeStyles({
avatar: {
height: 32,
width: 32,
borderRadius: '50%',
},
});
type User = {
gender: string; // "male"
name: {
title: string; // "Mr",
first: string; // "Duane",
last: string; // "Reed"
};
location: object; // {street: {number: 5060, name: "Hickory Creek Dr"}, city: "Albany", state: "New South Wales",…}
email: string; // "duane.reed@example.com"
login: object; // {uuid: "4b785022-9a23-4ab9-8a23-cb3fb43969a9", username: "blackdog796", password: "patch",…}
dob: object; // {date: "1983-06-22T12:30:23.016Z", age: 37}
registered: object; // {date: "2006-06-13T18:48:28.037Z", age: 14}
phone: string; // "07-2154-5651"
cell: string; // "0405-592-879"
id: {
name: string; // "TFN",
value: string; // "796260432"
};
picture: { medium: string }; // {medium: "https://randomuser.me/api/portraits/men/95.jpg",…}
nat: string; // "AU"
};
type DenseTableProps = {
users: User[];
};
export const DenseTable = ({ users }: DenseTableProps) => {
const classes = useStyles();
const columns: TableColumn[] = [
{ title: 'Avatar', field: 'avatar' },
{ title: 'Name', field: 'name' },
{ title: 'Email', field: 'email' },
{ title: 'Nationality', field: 'nationality' },
];
const data = users.map(user => {
return {
avatar: (
<img
src={user.picture.medium}
className={classes.avatar}
alt={user.name.first}
/>
),
name: `${user.name.first} ${user.name.last}`,
email: user.email,
nationality: user.nat,
};
});
return (
<Table
title="Example User List (fetching data from randomuser.me)"
options={{ search: false, paging: false }}
columns={columns}
data={data}
/>
);
};
export const OpenCostFetchComponent = () => {
const { fetch } = useApi(fetchApiRef);
const { value, loading, error } = useAsync(async (): Promise<User[]> => {
const response = await fetch('https://randomuser.me/api/?results=20');
const data = await response.json();
return data.results;
}, []);
if (loading) {
return <Progress />;
} else if (error) {
return <ResponseErrorPanel error={error} />;
}
return <DenseTable users={value || []} />;
};
@@ -37,6 +37,6 @@ describe('OpenCostPage', () => {
it('should render', async () => {
await renderInTestApp(<OpenCostPage />);
expect(screen.getByText('Welcome to OpenCost!')).toBeInTheDocument();
expect(screen.getByText('Open source Kubernetes cloud cost monitoring')).toBeInTheDocument();
});
});
@@ -14,14 +14,14 @@
* limitations under the License.
*/
import React from 'react';
import { Typography, Grid } from '@material-ui/core';
import { Grid } from '@material-ui/core';
import {
Header,
Page,
Content,
ContentHeader,
} from '@backstage/core-components';
import { OpenCostFetchComponent } from '../OpenCostFetchComponent';
import { OpenCostReport } from '../OpenCostReport';
export const OpenCostPage = () => (
<Page themeId="tool">
@@ -38,11 +38,10 @@ export const OpenCostPage = () => (
/>
</a>
</Header>
<Content>
<ContentHeader title="Namespaces " />
<Content>
<Grid container spacing={3} direction="column">
<Grid item>
<OpenCostFetchComponent />
<OpenCostReport />
</Grid>
</Grid>
</Content>
@@ -0,0 +1,270 @@
// 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, forEach, 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'
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
function generateTitle({ window, aggregateBy, accumulate }) {
let windowName = get(find(windowOptions, { value: window }), 'name', '')
if (windowName === '') {
if (checkCustomWindow(window)) {
windowName = toVerboseTimeRange(window)
} else {
console.warn(`unknown window: ${window}`)
}
}
let aggregationName = get(find(aggregationOptions, { value: aggregateBy }), 'name', '').toLowerCase()
if (aggregationName === '') {
console.warn(`unknown aggregation: ${aggregateBy}`)
}
let str = `${windowName} by ${aggregationName}`
if (!accumulate) {
str = `${str} daily`
}
return str
}
export const OpenCostReport = () => {
console.log("get the URL from the config")
const baseUrl = "http://localhost:9003";
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(() => {
const cumulative = rangeToCumulative(allocationData, aggregateBy)
setCumulativeData(toArray(cumulative))
setTotalData(cumulativeToTotals(cumulative))
}, [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(() => {
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()
}
}, [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');
}, [routerLocation]);
async function initialize() {
setInit(true)
}
async function fetchData() {
setLoading(true)
setErrors([])
try {
// get the baseUrl from the Basecamp config
console.log("get the baseUrl from the Basecamp config")
const resp = await AllocationService.fetchAllocation(baseUrl, window, aggregateBy, { accumulate })
if (resp.data && resp.data.length > 0) {
const allocationRange = resp.data
console.log(allocationRange);
for (const i in allocationRange) {
// 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([{
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([{
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}
setWindow={(win) => {
searchParams.set('window', win);
routerNavigate({
search: `?${searchParams.toString()}`,
});
}}
aggregationOptions={aggregationOptions}
aggregateBy={aggregateBy}
setAggregateBy={(agg) => {
searchParams.set('agg', agg);
routerNavigate({
search: `?${searchParams.toString()}`,
});
}}
accumulateOptions={accumulateOptions}
accumulate={accumulate}
setAccumulate={(acc) => {
searchParams.set('acc', acc);
routerNavigate({
search: `?${searchParams.toString()}`
});
}}
title={title}
cumulativeData={cumulativeData}
currency={currency}
currencyOptions={currencyCodes}
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>
)
}
@@ -13,4 +13,4 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { OpenCostFetchComponent } from './OpenCostFetchComponent';
export { OpenCostReport } from './OpenCostReport';
+33
View File
@@ -0,0 +1,33 @@
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,190 @@
import React, { useEffect, useState } from 'react'
import { makeStyles } from '@material-ui/styles'
import { endOfDay, startOfDay } from 'date-fns'
import { MuiPickersUtilsProvider, KeyboardDatePicker } from '@material-ui/pickers'
import Button from '@material-ui/core/Button'
import DateFnsUtils from '@date-io/date-fns'
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
let adjustedStartDate = new Date(startDate - startDate.getTimezoneOffset() * 60000)
let 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={DateFnsUtils}>
<KeyboardDatePicker
style={{ width: '144px' }}
autoOk={true}
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={true}
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,41 @@
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,37 @@
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
+38
View File
@@ -0,0 +1,38 @@
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 @@
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"];
@@ -0,0 +1,23 @@
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;
}
console.log(`${baseUrl}/allocation/compute`, { params });
const result = await axios.get(`${baseUrl}/allocation/compute`, { params });
console.log(result.data);
return result.data;
}
}
export default new AllocationService();
+302
View File
@@ -0,0 +1,302 @@
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) {
let 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`
}
const splitDates = window.split(",")
if (checkCustomWindow(window) && splitDates.length > 1) {
let s = splitDates[0].split(/\D+/).slice(0, 3)
let 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()}`
} else {
return `${start.getUTCDate()} ${months[start.getUTCMonth()]} ${start.getUTCFullYear()} through ${end.getUTCDate()} ${months[end.getUTCMonth()]} ${end.getUTCFullYear()}`
}
}
}
return null
}
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 ""
}
if (currency === undefined || currency === "") {
currency = "USD"
}
const opts = {
style: "currency",
currency: currency,
}
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,
}