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:
+14
-24
@@ -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
|
||||

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