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 +![Screenshot](screenshot.png) 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 + + + + Resolution + + + + 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: ( - {user.name.first} - ), - 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 ( +
+
+
+ {props.children} +
+
+
+ ) +} + +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' }, + }} + /> + + +
+
+ + + + +
+ +
+
+
+ {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, +}