diff --git a/plugins/opencost/README.md b/plugins/opencost/README.md
index d03ff4d624..2a6cacdbb5 100644
--- a/plugins/opencost/README.md
+++ b/plugins/opencost/README.md
@@ -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
+
diff --git a/plugins/opencost/config.d.ts b/plugins/opencost/config.d.ts
new file mode 100644
index 0000000000..4af53bddce
--- /dev/null
+++ b/plugins/opencost/config.d.ts
@@ -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;
+ };
+}
diff --git a/plugins/opencost/screenshot.png b/plugins/opencost/screenshot.png
new file mode 100644
index 0000000000..e8added76f
Binary files /dev/null and b/plugins/opencost/screenshot.png differ
diff --git a/plugins/opencost/src/components/AllocationChart/RangeChart.js b/plugins/opencost/src/components/AllocationChart/RangeChart.js
new file mode 100644
index 0000000000..3410ad24ad
--- /dev/null
+++ b/plugins/opencost/src/components/AllocationChart/RangeChart.js
@@ -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 (
+
+
{`Total: ${toCurrency(total, currency)}`}
+ {reverse(payload).map((item, i) => (
+
{`${item.name}: ${toCurrency(item.value, currency)}`}
+ ))}
+
+ )
+ }
+
+ return null
+ }
+
+ return (
+
+
+
+
+
+ } />
+ {barLabels.map((barLabel, i) => )}
+
+
+ )
+}
+
+export default RangeChart
diff --git a/plugins/opencost/src/components/AllocationChart/SummaryChart.js b/plugins/opencost/src/components/AllocationChart/SummaryChart.js
new file mode 100644
index 0000000000..61625e151e
--- /dev/null
+++ b/plugins/opencost/src/components/AllocationChart/SummaryChart.js
@@ -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 (
+ cx ? 'start' : 'end'} dominantBaseline="central">
+ {`${name}: ${toCurrency(value, currency)} (${(percent * 100).toFixed(1)}%)`}
+
+ )
+ }
+
+ return (
+
+
+
+ {pieData.map((datum, i) => | )}
+
+
+
+ )
+}
+
+export default SummaryChart
diff --git a/plugins/opencost/src/components/AllocationChart/index.js b/plugins/opencost/src/components/AllocationChart/index.js
new file mode 100644
index 0000000000..a54cea13ec
--- /dev/null
+++ b/plugins/opencost/src/components/AllocationChart/index.js
@@ -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 No data
+ }
+
+ if (allocationRange.length === 1) {
+ const datum = top(n, alloc => alloc.totalCost)(allocationRange[0])
+ return
+ }
+
+ const data = top(n, alloc => alloc.totalCost)(allocationRange)
+ return
+}
+
+export default React.memo(AllocationChart)
diff --git a/plugins/opencost/src/components/AllocationReport.js b/plugins/opencost/src/components/AllocationReport.js
new file mode 100644
index 0000000000..7447c05b35
--- /dev/null
+++ b/plugins/opencost/src/components/AllocationReport.js
@@ -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 No results
+ }
+
+ const [order, setOrder] = React.useState('desc')
+ const [orderBy, setOrderBy] = React.useState('totalCost')
+ const [page, setPage] = useState(0)
+ const [rowsPerPage, setRowsPerPage] = useState(25)
+ const numData = cumulativeData.length
+
+ useEffect(() => {
+ setPage(0)
+ }, [numData])
+
+ const lastPage = Math.floor(numData / rowsPerPage)
+
+ const handleChangePage = (event, newPage) => setPage(newPage)
+
+ const handleChangeRowsPerPage = event => {
+ setRowsPerPage(parseInt(event.target.value, 10))
+ setPage(0)
+ }
+
+ const 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 (
+
+
+
+
+
+
+ {headCells.map((cell) => (
+
+
+ {cell.label}
+
+
+ ))}
+
+
+
+
+ {headCells.map((cell) => {
+ return (
+
+ {cell.numeric
+ ? (cell.label === 'Efficiency'
+ ? (totalData.totalEfficiency == 1.0 && totalData.cpuReqCoreHrs == 0 && totalData.ramReqByteHrs == 0)
+ ? "Inf%"
+ : `${round(totalData.totalEfficiency*100, 1)}%`
+ : toCurrency(totalData[cell.id], currency))
+ : totalData[cell.id]}
+
+ )})}
+
+ {pageRows.map((row, key) => {
+ if (row.name === "__unmounted__") {
+ row.name = "Unmounted PVs"
+ }
+
+ 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 (
+
+ {row.name}
+ {toCurrency(row.cpuCost, currency)}
+ {toCurrency(row.ramCost, currency)}
+ {toCurrency(row.pvCost, currency)}
+ {isIdle ? (
+ —
+ ) : (
+ {efficiency}%
+ )}
+ {toCurrency(row.totalCost, currency)}
+
+ )
+ }
+
+ return (
+
+ {row.name}
+ {toCurrency(row.cpuCost, currency)}
+ {toCurrency(row.ramCost, currency)}
+ {toCurrency(row.pvCost, currency)}
+ {efficiency}%
+ {toCurrency(row.totalCost, currency)}
+
+ )
+ })}
+
+
+
+
+
+ )
+}
+
+export default React.memo(AllocationReport)
diff --git a/plugins/opencost/src/components/Controls/Download.js b/plugins/opencost/src/components/Controls/Download.js
new file mode 100644
index 0000000000..ae111376f6
--- /dev/null
+++ b/plugins/opencost/src/components/Controls/Download.js
@@ -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 (
+
+
+
+
+
+ )
+}
+
+export default React.memo(DownloadControl)
diff --git a/plugins/opencost/src/components/Controls/Edit.js b/plugins/opencost/src/components/Controls/Edit.js
new file mode 100644
index 0000000000..2054672518
--- /dev/null
+++ b/plugins/opencost/src/components/Controls/Edit.js
@@ -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 (
+
+
+
+ Breakdown
+ {
+ setAggregateBy(e.target.value)
+ }}
+ >
+ {aggregationOptions.map((opt) => {opt.name} )}
+
+
+
+ Resolution
+ setAccumulate(e.target.value)}
+ >
+ {accumulateOptions.map((opt) => {opt.name} )}
+
+
+
+ Currency
+ setCurrency(e.target.value)}
+ >
+ {currencyOptions?.map((currency) => (
+
+ {currency}
+
+ ))}
+
+
+
+ );
+}
+
+export default React.memo(EditControl);
diff --git a/plugins/opencost/src/components/Controls/index.js b/plugins/opencost/src/components/Controls/index.js
new file mode 100644
index 0000000000..fe687e51be
--- /dev/null
+++ b/plugins/opencost/src/components/Controls/index.js
@@ -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 (
+
+
+
+
+ )
+}
+
+export default React.memo(Controls)
diff --git a/plugins/opencost/src/components/Details.js b/plugins/opencost/src/components/Details.js
new file mode 100644
index 0000000000..9cc45d0913
--- /dev/null
+++ b/plugins/opencost/src/components/Details.js
@@ -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 (
+
+ )
+ }
+
+ return (
+
+
+ {!loading && errors.length > 0 && (
+
+
+
+ )}
+
+
+ {cluster && (
+
+
+
+
+
+
+ )}
+ {node && (
+
+
+
+
+
+
+ )}
+
+
+
+
+
+ Container
+ Hours
+ CPU
+ $/(CPU*Hr)
+ CPU cost
+ RAM
+ $/(GiB*Hr)
+ RAM cost
+ Total cost
+
+
+
+ {rows.map((row, i) => (
+
+ {row.container}
+ {row.hours}
+ {row.cpu}
+ {toCurrency(row.cpuCostPerCoreHr, currency, 5)}
+ {toCurrency(row.cpuCost, currency, 3)}
+ {bytesToString(row.ram)}
+ {toCurrency(row.ramCostPerGiBHr, currency, 5)}
+ {toCurrency(row.ramCost, currency, 3)}
+ {toCurrency(row.totalCost, currency, 3)}
+
+ ))}
+
+
+
+
+ )
+}
+
+export default memo(Details)
diff --git a/plugins/opencost/src/components/OpenCostFetchComponent/OpenCostFetchComponent.test.tsx b/plugins/opencost/src/components/OpenCostFetchComponent/OpenCostFetchComponent.test.tsx
deleted file mode 100644
index 0537e056fd..0000000000
--- a/plugins/opencost/src/components/OpenCostFetchComponent/OpenCostFetchComponent.test.tsx
+++ /dev/null
@@ -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( );
- expect(await screen.findByTestId('progress')).toBeInTheDocument();
- });
-});
diff --git a/plugins/opencost/src/components/OpenCostFetchComponent/OpenCostFetchComponent.tsx b/plugins/opencost/src/components/OpenCostFetchComponent/OpenCostFetchComponent.tsx
deleted file mode 100644
index 80db2be5e1..0000000000
--- a/plugins/opencost/src/components/OpenCostFetchComponent/OpenCostFetchComponent.tsx
+++ /dev/null
@@ -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: (
-
- ),
- name: `${user.name.first} ${user.name.last}`,
- email: user.email,
- nationality: user.nat,
- };
- });
-
- return (
-
- );
-};
-
-export const OpenCostFetchComponent = () => {
- const { fetch } = useApi(fetchApiRef);
- const { value, loading, error } = useAsync(async (): Promise => {
- const response = await fetch('https://randomuser.me/api/?results=20');
- const data = await response.json();
- return data.results;
- }, []);
-
- if (loading) {
- return ;
- } else if (error) {
- return ;
- }
-
- return ;
-};
diff --git a/plugins/opencost/src/components/OpenCostPage/OpenCostPage.test.tsx b/plugins/opencost/src/components/OpenCostPage/OpenCostPage.test.tsx
index 99f7d51cc3..de6565246c 100644
--- a/plugins/opencost/src/components/OpenCostPage/OpenCostPage.test.tsx
+++ b/plugins/opencost/src/components/OpenCostPage/OpenCostPage.test.tsx
@@ -37,6 +37,6 @@ describe('OpenCostPage', () => {
it('should render', async () => {
await renderInTestApp( );
- expect(screen.getByText('Welcome to OpenCost!')).toBeInTheDocument();
+ expect(screen.getByText('Open source Kubernetes cloud cost monitoring')).toBeInTheDocument();
});
});
diff --git a/plugins/opencost/src/components/OpenCostPage/OpenCostPage.tsx b/plugins/opencost/src/components/OpenCostPage/OpenCostPage.tsx
index faf3ca1a49..88ef390c49 100644
--- a/plugins/opencost/src/components/OpenCostPage/OpenCostPage.tsx
+++ b/plugins/opencost/src/components/OpenCostPage/OpenCostPage.tsx
@@ -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 = () => (
@@ -38,11 +38,10 @@ export const OpenCostPage = () => (
/>
-
-
+
-
+
diff --git a/plugins/opencost/src/components/OpenCostReport/OpenCostReport.tsx b/plugins/opencost/src/components/OpenCostReport/OpenCostReport.tsx
new file mode 100644
index 0000000000..0f25d72f3d
--- /dev/null
+++ b/plugins/opencost/src/components/OpenCostReport/OpenCostReport.tsx
@@ -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 (
+
+
+ {!loading && errors.length > 0 && (
+
+
+
+ )}
+
+ {init &&
+
+
+ {title}
+
+
+
+
setFetch(true)}>
+
+
+
+
{
+ 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()}`
+ });
+ }}
+ />
+
+
+ {loading && (
+
+ )}
+ {!loading && (
+
+ )}
+ }
+
+)
+}
diff --git a/plugins/opencost/src/components/OpenCostFetchComponent/index.ts b/plugins/opencost/src/components/OpenCostReport/index.ts
similarity index 90%
rename from plugins/opencost/src/components/OpenCostFetchComponent/index.ts
rename to plugins/opencost/src/components/OpenCostReport/index.ts
index 91c904e5e7..aee85ea8db 100644
--- a/plugins/opencost/src/components/OpenCostFetchComponent/index.ts
+++ b/plugins/opencost/src/components/OpenCostReport/index.ts
@@ -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';
diff --git a/plugins/opencost/src/components/Page.js b/plugins/opencost/src/components/Page.js
new file mode 100644
index 0000000000..6dfe640ac4
--- /dev/null
+++ b/plugins/opencost/src/components/Page.js
@@ -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 (
+
+ )
+}
+
+export default Page
diff --git a/plugins/opencost/src/components/SelectWindow.js b/plugins/opencost/src/components/SelectWindow.js
new file mode 100644
index 0000000000..32957838c1
--- /dev/null
+++ b/plugins/opencost/src/components/SelectWindow.js
@@ -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 (
+ <>
+
+ handleClick(e)}
+ inputProps={{
+ readOnly: true,
+ style: { cursor: 'pointer' },
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+ Apply
+
+
+
+
+ {windowOptions.map(opt =>
+
+ handleSubmitPresetDates(opt.value)}
+ >
+ {opt.name}
+
+
+ )}
+
+
+
+ >
+ )
+ }
+
+ export default React.memo(SelectWindow)
\ No newline at end of file
diff --git a/plugins/opencost/src/components/Subtitle.js b/plugins/opencost/src/components/Subtitle.js
new file mode 100644
index 0000000000..44a26f3a75
--- /dev/null
+++ b/plugins/opencost/src/components/Subtitle.js
@@ -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 (
+
+ }
+ aria-label="breadcrumb"
+ >
+ {aggregateBy && aggregateBy.length > 0 ? (
+ {toVerboseTimeRange(window)} by {upperFirst(aggregateBy)}
+ ) : (
+ {toVerboseTimeRange(window)}
+ )}
+
+
+ )
+}
+
+export default React.memo(Subtitle)
diff --git a/plugins/opencost/src/components/Warnings.js b/plugins/opencost/src/components/Warnings.js
new file mode 100644
index 0000000000..967fc6fde8
--- /dev/null
+++ b/plugins/opencost/src/components/Warnings.js
@@ -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 (
+
+
+ {warnings.map((warn, i) => (
+
+
+
+
+
+
+ ))}
+
+
+ )
+}
+
+export default Warnings
diff --git a/plugins/opencost/src/constants/colors.js b/plugins/opencost/src/constants/colors.js
new file mode 100644
index 0000000000..4c66549ad0
--- /dev/null
+++ b/plugins/opencost/src/constants/colors.js
@@ -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],
+]
diff --git a/plugins/opencost/src/constants/currencyCodes.js b/plugins/opencost/src/constants/currencyCodes.js
new file mode 100644
index 0000000000..219384c24b
--- /dev/null
+++ b/plugins/opencost/src/constants/currencyCodes.js
@@ -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"];
diff --git a/plugins/opencost/src/services/allocation.js b/plugins/opencost/src/services/allocation.js
new file mode 100644
index 0000000000..8b94c4945e
--- /dev/null
+++ b/plugins/opencost/src/services/allocation.js
@@ -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();
diff --git a/plugins/opencost/src/util.js b/plugins/opencost/src/util.js
new file mode 100644
index 0000000000..8e4bfcb54d
--- /dev/null
+++ b/plugins/opencost/src/util.js
@@ -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,
+}