diff --git a/.changeset/slow-queens-visit.md b/.changeset/slow-queens-visit.md
new file mode 100644
index 0000000000..2df948e477
--- /dev/null
+++ b/.changeset/slow-queens-visit.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-opencost': minor
+---
+
+New OpenCost plugin provides an port of the latest OpenCost UI to Backstage with updated dependencies. The plugin's README covers installation and configuration
diff --git a/plugins/opencost/.eslintrc.js b/plugins/opencost/.eslintrc.js
new file mode 100644
index 0000000000..e2a53a6ad2
--- /dev/null
+++ b/plugins/opencost/.eslintrc.js
@@ -0,0 +1 @@
+module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
diff --git a/plugins/opencost/README.md b/plugins/opencost/README.md
new file mode 100644
index 0000000000..39c5e7ee2e
--- /dev/null
+++ b/plugins/opencost/README.md
@@ -0,0 +1,79 @@
+# OpenCost
+
+Welcome to the [OpenCost](https://opencost.io) plugin!
+
+Currently this is a port of the [OpenCost UI](https://github.com/opencost/opencost/tree/develop/ui), but we will continue to expand it to expose any relevant data or pre-configured views that may be preferred.
+
+All of the code was originally ported from https://github.com/opencost/opencost/blob/develop/ui/ which is under by the Apache v2 License and also managed by the CNCF.
+
+## Installation
+
+1. Add the OpenCost dependency to the `packages/app/package.json`:
+ ```sh
+ # From your Backstage root directory
+ yarn add --cwd packages/app @backstage/plugin-opencost
+ ```
+2. Add the `OpenCostPage` to your `packages/app/src/App.tsx`:
+
+ ```tsx
+ import { OpenCostPage } from '@backstage/plugin-opencost';
+ ```
+
+and
+
+ ```tsx
+
+ …
+ } />
+
+ ```
+
+3. Add link to OpenCost to your sidebar
+
+ ```typescript
+ // packages/app/src/components/Root/Root.tsx
+ import MoneyIcon from '@material-ui/icons/MonetizationOn';
+
+ ...
+
+ export const Root = ({ children }: PropsWithChildren<{}>) => (
+
+
+ ...
+
+ ...
+
+
+ );
+
+ ```
+
+## Plugin Configuration
+
+Since OpenCost doesn't have any authentication at this point, you just need to give API access to the plugin to access your data.
+
+If you haven't set up an ingress rule, you can port-forward the API with
+
+```
+kubectl -n opencost port-forward deployment/opencost 9003
+```
+
+Add the following to your `app-config.yaml`:
+
+```yaml
+opencost:
+ baseUrl: http://localhost:9003
+```
+
+## Ideas/Next Steps
+
+- More testing
+- Use the OpenCost mascot for the sidebar logo
+- Use the Backstage proxy to communicate with the OpenCost API if necessary for authentication
+- Convert AllocationReport.js to use the [Backstage Table](https://backstage.io/storybook/?path=/story/data-display-table--default-table)
+- Allow for user-provided default reports and/or disabling controls
+- Support multiple hard-coded reports
+- clean up deprecation warnings and upgrade to all the latest React components
+- Fork(?) to support `Kubecost`, which could provide Alerts and Recommendations, similar to the Cost Explorer plugin
+
+
diff --git a/plugins/opencost/api-report.md b/plugins/opencost/api-report.md
new file mode 100644
index 0000000000..ee9026cbb7
--- /dev/null
+++ b/plugins/opencost/api-report.md
@@ -0,0 +1,22 @@
+## API Report File for "@backstage/plugin-opencost"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+///
+
+import { BackstagePlugin } from '@backstage/core-plugin-api';
+import { RouteRef } from '@backstage/core-plugin-api';
+
+// @public (undocumented)
+export const OpenCostPage: () => JSX.Element;
+
+// @public (undocumented)
+export const openCostPlugin: BackstagePlugin<
+ {
+ root: RouteRef;
+ },
+ {},
+ {}
+>;
+```
diff --git a/plugins/opencost/config.d.ts b/plugins/opencost/config.d.ts
new file mode 100644
index 0000000000..0070bec7b7
--- /dev/null
+++ b/plugins/opencost/config.d.ts
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export interface Config {
+ /**
+ * Configurations for the OpenCost plugin
+ *
+ * @visibility frontend
+ */
+ opencost?: {
+ /**
+ * The base URL for the OpenCost API
+ * @visibility frontend
+ */
+ baseUrl: string;
+ };
+}
diff --git a/plugins/opencost/dev/index.tsx b/plugins/opencost/dev/index.tsx
new file mode 100644
index 0000000000..41b2bd5346
--- /dev/null
+++ b/plugins/opencost/dev/index.tsx
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import React from 'react';
+import { createDevApp } from '@backstage/dev-utils';
+import { openCostPlugin, OpenCostPage } from '../src/plugin';
+
+createDevApp()
+ .registerPlugin(openCostPlugin)
+ .addPage({
+ element: ,
+ title: 'Root Page',
+ path: '/opencost',
+ })
+ .render();
diff --git a/plugins/opencost/package.json b/plugins/opencost/package.json
new file mode 100644
index 0000000000..af2a275a52
--- /dev/null
+++ b/plugins/opencost/package.json
@@ -0,0 +1,60 @@
+{
+ "name": "@backstage/plugin-opencost",
+ "version": "0.1.0",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "license": "Apache-2.0",
+ "publishConfig": {
+ "access": "public",
+ "main": "dist/index.esm.js",
+ "types": "dist/index.d.ts"
+ },
+ "backstage": {
+ "role": "frontend-plugin"
+ },
+ "scripts": {
+ "start": "backstage-cli package start",
+ "build": "backstage-cli package build",
+ "lint": "backstage-cli package lint",
+ "test": "backstage-cli package test",
+ "clean": "backstage-cli package clean",
+ "prepack": "backstage-cli package prepack",
+ "postpack": "backstage-cli package postpack"
+ },
+ "dependencies": {
+ "@backstage/core-components": "workspace:^",
+ "@backstage/core-plugin-api": "workspace:^",
+ "@backstage/theme": "workspace:^",
+ "@date-io/luxon": "1.x",
+ "@material-ui/core": "^4.9.13",
+ "@material-ui/icons": "^4.9.1",
+ "@material-ui/lab": "^4.0.0-alpha.60",
+ "@material-ui/pickers": "^3.3.10",
+ "@material-ui/styles": "^4.11.5",
+ "axios": "^1.4.0",
+ "date-fns": "^2.30.0",
+ "lodash": "^4.17.21",
+ "react-use": "^17.2.4",
+ "recharts": "^2.5.0"
+ },
+ "peerDependencies": {
+ "react": "^16.13.1 || ^17.0.0",
+ "react-router-dom": "*"
+ },
+ "devDependencies": {
+ "@backstage/cli": "workspace:^",
+ "@backstage/core-app-api": "workspace:^",
+ "@backstage/dev-utils": "workspace:^",
+ "@backstage/test-utils": "workspace:^",
+ "@testing-library/jest-dom": "^5.10.1",
+ "@testing-library/react": "^12.1.3",
+ "@testing-library/user-event": "^14.0.0",
+ "cross-fetch": "^3.1.5",
+ "msw": "^1.0.0"
+ },
+ "files": [
+ "dist",
+ "config.d.ts"
+ ],
+ "configSchema": "config.d.ts"
+}
diff --git a/plugins/opencost/screenshot.png b/plugins/opencost/screenshot.png
new file mode 100644
index 0000000000..432b525cb1
Binary files /dev/null and b/plugins/opencost/screenshot.png differ
diff --git a/plugins/opencost/src/components/AllocationChart/RangeChart.jsx b/plugins/opencost/src/components/AllocationChart/RangeChart.jsx
new file mode 100644
index 0000000000..b1ce5a34ee
--- /dev/null
+++ b/plugins/opencost/src/components/AllocationChart/RangeChart.jsx
@@ -0,0 +1,201 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import React from 'react';
+import {
+ BarChart,
+ Bar,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ ResponsiveContainer,
+} from 'recharts';
+import { makeStyles } from '@material-ui/styles';
+import { reverse } from 'lodash';
+import { primary, greyscale, browns } from '../../constants/colors';
+import { toCurrency } from '../../util';
+
+const useStyles = makeStyles({
+ tooltip: {
+ borderRadius: 2,
+ background: 'rgba(255, 255, 255, 0.95)',
+ padding: 12,
+ },
+ tooltipLineItem: {
+ fontSize: '1rem',
+ margin: 0,
+ marginBottom: 4,
+ padding: 0,
+ },
+});
+
+function toBarLabels(allocationRange) {
+ const keyToFill = {};
+ let p = 0;
+ let g = 0;
+ let b = 0;
+
+ for (const { idle } of allocationRange) {
+ for (const allocation of idle) {
+ const key = allocation.name;
+ if (keyToFill[key] === undefined) {
+ // idle allocations are assigned grey
+ keyToFill[key] = greyscale[g];
+ g = (g + 1) % greyscale.length;
+ }
+ }
+ }
+
+ for (const { top } of allocationRange) {
+ for (const allocation of top) {
+ const key = allocation.name;
+ if (keyToFill[key] === undefined) {
+ if (key === '__unallocated__') {
+ // unallocated gets black (clean up)
+ keyToFill[key] = '#212121';
+ } else {
+ // non-idle allocations get the next available color
+ keyToFill[key] = primary[p];
+ p = (p + 1) % primary.length;
+ }
+ }
+ }
+ }
+
+ for (const { other } of allocationRange) {
+ for (const allocation of other) {
+ const key = allocation.name;
+ if (keyToFill[key] === undefined) {
+ // idle allocations are assigned grey
+ keyToFill[key] = browns[b];
+ b = (b + 1) % browns.length;
+ }
+ }
+ }
+
+ const labels = [];
+ for (const key in keyToFill) {
+ if (Object.hasOwn(keyToFill, key)) {
+ labels.push({
+ dataKey: key,
+ fill: keyToFill[key],
+ });
+ }
+ }
+
+ return reverse(labels);
+}
+
+function toBar(datum) {
+ const { top, other, idle } = datum;
+ const bar = {};
+
+ for (const key in top) {
+ if (Object.hasOwn(top, key)) {
+ const allocation = top[key];
+ const start = new Date(allocation.start);
+ bar.start = `${start.getUTCFullYear()}-${
+ start.getUTCMonth() + 1
+ }-${start.getUTCDate()}`;
+ bar[allocation.name] = allocation.totalCost;
+ }
+ }
+
+ for (const key in other) {
+ if (Object.hasOwn(other, key)) {
+ const allocation = other[key];
+ const start = new Date(allocation.start);
+ bar.start = `${start.getUTCFullYear()}-${
+ start.getUTCMonth() + 1
+ }-${start.getUTCDate()}`;
+ bar[allocation.name] = allocation.totalCost;
+ }
+ }
+
+ for (const key in idle) {
+ if (Object.hasOwn(idle, key)) {
+ const allocation = idle[key];
+ const start = new Date(allocation.start);
+ bar.start = `${start.getUTCFullYear()}-${
+ start.getUTCMonth() + 1
+ }-${start.getUTCDate()}`;
+ bar[allocation.name] = allocation.totalCost;
+ }
+ }
+
+ return bar;
+}
+
+const RangeChart = ({ data, currency, height }) => {
+ const classes = useStyles();
+
+ const barData = data.map(toBar);
+ const barLabels = toBarLabels(data);
+
+ const CustomTooltip = params => {
+ const { active, payload } = params;
+
+ if (!payload || payload.length === 0) {
+ return null;
+ }
+
+ const total = payload.reduce((sum, item) => sum + item.value, 0.0);
+ if (active) {
+ /* eslint react/forbid-elements: [0, { allow: ["warning"] }] */
+ return (
+
+ );
+}
+
+export default React.memo(EditControl);
diff --git a/plugins/opencost/src/components/Controls/index.jsx b/plugins/opencost/src/components/Controls/index.jsx
new file mode 100644
index 0000000000..c63833a6ab
--- /dev/null
+++ b/plugins/opencost/src/components/Controls/index.jsx
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import React from 'react';
+import DownloadControl from './Download';
+import EditControl from './Edit';
+
+const Controls = ({
+ windowOptions,
+ window,
+ setWindow,
+ aggregationOptions,
+ aggregateBy,
+ setAggregateBy,
+ accumulateOptions,
+ accumulate,
+ setAccumulate,
+ title,
+ cumulativeData,
+ currency,
+ currencyOptions,
+ setCurrency,
+}) => {
+ return (
+
+
+
+
+ );
+};
+
+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..5245c9e9fa
--- /dev/null
+++ b/plugins/opencost/src/components/Details.js
@@ -0,0 +1,287 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React, { memo, useEffect, useState } from 'react';
+import { forEach, get, reverse, round, sortBy } from 'lodash';
+import CircularProgress from '@material-ui/core/CircularProgress';
+import ClusterIcon from '@material-ui/icons/GroupWork';
+import NodeIcon from '@material-ui/icons/Memory';
+import List from '@material-ui/core/List';
+import ListItem from '@material-ui/core/ListItem';
+import ListItemIcon from '@material-ui/core/ListItemIcon';
+import ListItemText from '@material-ui/core/ListItemText';
+import Table from '@material-ui/core/Table';
+import TableBody from '@material-ui/core/TableBody';
+import TableCell from '@material-ui/core/TableCell';
+import TableContainer from '@material-ui/core/TableContainer';
+import TableHead from '@material-ui/core/TableHead';
+import TableRow from '@material-ui/core/TableRow';
+import Warnings from './Warnings';
+import AllocationService from '../services/allocation';
+import { bytesToString, toCurrency } from '../util';
+
+const Details = ({
+ window,
+ namespace,
+ controllerKind,
+ controller,
+ pod,
+ currency,
+}) => {
+ const [cluster, setCluster] = useState('');
+ const [node, setNode] = useState('');
+
+ const [fetch, setFetch] = useState(true);
+ const [loading, setLoading] = useState(false);
+ const [errors, setErrors] = useState([]);
+ const [rows, setRows] = useState([]);
+
+ useEffect(() => {
+ if (fetch) {
+ setCluster('');
+ setNode('');
+ fetchData();
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [fetch]);
+
+ async function fetchData() {
+ setLoading(true);
+ setErrors([]);
+
+ try {
+ const filters = [];
+
+ if (cluster) {
+ filters.push({
+ property: 'cluster',
+ value: cluster,
+ });
+ }
+
+ if (node) {
+ filters.push({
+ property: 'node',
+ value: node,
+ });
+ }
+
+ if (namespace) {
+ filters.push({
+ property: 'namespace',
+ value: namespace,
+ });
+ }
+
+ if (controllerKind) {
+ filters.push({
+ property: 'controllerKind',
+ value: controllerKind,
+ });
+ }
+
+ if (controller) {
+ filters.push({
+ property: 'controller',
+ value: controller,
+ });
+ }
+
+ if (pod) {
+ filters.push({
+ property: 'pod',
+ value: pod,
+ });
+ }
+
+ const resp = await AllocationService.fetchAllocation(window, '', {
+ accumulate: true,
+ });
+
+ let data = [];
+ forEach(resp.data[0], datum => {
+ if (datum.name === '__idle__') {
+ return;
+ }
+
+ if (!cluster) {
+ setCluster(get(datum, 'properties.cluster', ''));
+ }
+
+ if (!node) {
+ setNode(get(datum, 'properties.node', ''));
+ }
+
+ // TODO can we get pod, container back in properties?
+ const names = datum.name.split('/');
+ datum.pod = names[names.length - 2];
+ datum.container = names[names.length - 1];
+
+ datum.hours = round(get(datum, 'minutes', 0.0) / 60.0, 2);
+
+ if (datum.hours > 0) {
+ datum.cpu = round(get(datum, 'cpuCoreHours', 0.0) / datum.hours, 2);
+ datum.cpuCostPerCoreHr = datum.cpuCost / (datum.cpu * datum.hours);
+ if (datum.cpu === 0) {
+ datum.cpuCostPerCoreHr = 0.0;
+ }
+
+ datum.ram = round(get(datum, 'ramByteHours', 0.0) / datum.hours, 2);
+ const ramGiB = datum.ram / 1024 / 1024 / 1024;
+ datum.ramCostPerGiBHr = datum.ramCost / (ramGiB * datum.hours);
+ if (ramGiB === 0) {
+ datum.ramCostPerGiBHr = 0.0;
+ }
+ } else {
+ datum.cpu = 0.0;
+ datum.cpuCostPerCoreHr = 0.0;
+ datum.ram = 0.0;
+ datum.ramCostPerGiBHr = 0.0;
+ }
+
+ data.push(datum);
+ });
+
+ data = reverse(sortBy(data, 'totalCost'));
+
+ setRows(data);
+ } catch (e) {
+ /* eslint no-console: ["error", { allow: ["warn"] }] */
+ console.warn(
+ `Error fetching details for (${controllerKind}, ${controller}):`,
+ e,
+ );
+ setErrors([
+ {
+ primary: 'Error fetching details',
+ secondary: `Tried fetching details for: ${namespace}, ${controllerKind}, ${controller}, ${pod}`,
+ },
+ ]);
+ }
+
+ setLoading(false);
+ setFetch(false);
+ }
+
+ if (loading) {
+ return (
+
+ )}
+ {!loading && (
+
+ )}
+
+ )}
+
+ );
+};
diff --git a/plugins/opencost/src/components/OpenCostReport/index.ts b/plugins/opencost/src/components/OpenCostReport/index.ts
new file mode 100644
index 0000000000..aee85ea8db
--- /dev/null
+++ b/plugins/opencost/src/components/OpenCostReport/index.ts
@@ -0,0 +1,16 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+export { OpenCostReport } from './OpenCostReport';
diff --git a/plugins/opencost/src/components/Page.jsx b/plugins/opencost/src/components/Page.jsx
new file mode 100644
index 0000000000..6346b6287b
--- /dev/null
+++ b/plugins/opencost/src/components/Page.jsx
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { makeStyles } from '@material-ui/styles';
+import React from 'react';
+
+const useStyles = makeStyles({
+ wrapper: {
+ display: 'flex',
+ flexFlow: 'column',
+ flexGrow: 1,
+ margin: '20px 30px 0 30px',
+ minWidth: 800,
+ },
+ flexGrow: {
+ display: 'flex',
+ flexFlow: 'column',
+ flexGrow: 1,
+ },
+});
+
+const Page = props => {
+ const classes = useStyles();
+
+ return (
+
+
+
{props.children}
+
+
+ );
+};
+
+export default Page;
diff --git a/plugins/opencost/src/components/SelectWindow.jsx b/plugins/opencost/src/components/SelectWindow.jsx
new file mode 100644
index 0000000000..e374571c00
--- /dev/null
+++ b/plugins/opencost/src/components/SelectWindow.jsx
@@ -0,0 +1,214 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React, { useEffect, useState } from 'react';
+import { makeStyles } from '@material-ui/styles';
+import { endOfDay, startOfDay } from '@date-io/luxon';
+import {
+ MuiPickersUtilsProvider,
+ KeyboardDatePicker,
+} from '@material-ui/pickers';
+import Button from '@material-ui/core/Button';
+import LuxonUtils from '@date-io/luxon';
+import FormControl from '@material-ui/core/FormControl';
+import Link from '@material-ui/core/Link';
+import Popover from '@material-ui/core/Popover';
+import TextField from '@material-ui/core/TextField';
+import Typography from '@material-ui/core/Typography';
+import { isValid } from 'date-fns';
+import { find, get } from 'lodash';
+
+const useStyles = makeStyles({
+ dateContainer: {
+ paddingLeft: 18,
+ paddingRight: 18,
+ paddingTop: 6,
+ paddingBottom: 18,
+ display: 'flex',
+ flexFlow: 'row',
+ },
+ dateContainerColumn: {
+ display: 'flex',
+ flexFlow: 'column',
+ },
+ formControl: {
+ margin: 8,
+ width: 120,
+ },
+});
+
+const SelectWindow = ({ windowOptions, window, setWindow }) => {
+ const classes = useStyles();
+ const [anchorEl, setAnchorEl] = useState(null);
+
+ const [startDate, setStartDate] = useState(null);
+ const [endDate, setEndDate] = useState(null);
+ const [intervalString, setIntervalString] = useState(null);
+
+ const handleClick = event => {
+ setAnchorEl(event.currentTarget);
+ };
+
+ const handleClose = () => {
+ setAnchorEl(null);
+ };
+
+ const handleStartDateChange = date => {
+ if (isValid(date)) {
+ setStartDate(startOfDay(date));
+ }
+ };
+
+ const handleEndDateChange = date => {
+ if (isValid(date)) {
+ setEndDate(endOfDay(date));
+ }
+ };
+
+ const handleSubmitPresetDates = dateString => {
+ setWindow(dateString);
+ setStartDate(null);
+ setEndDate(null);
+ handleClose();
+ };
+
+ const handleSubmitCustomDates = () => {
+ if (intervalString !== null) {
+ setWindow(intervalString);
+ handleClose();
+ }
+ };
+
+ useEffect(() => {
+ if (startDate !== null && endDate !== null) {
+ // Note: getTimezoneOffset() is calculated based on current system locale, NOT date object
+ const adjustedStartDate = new Date(
+ startDate - startDate.getTimezoneOffset() * 60000,
+ );
+ const adjustedEndDate = new Date(
+ endDate - endDate.getTimezoneOffset() * 60000,
+ );
+ setIntervalString(
+ `${adjustedStartDate.toISOString().split('.')[0]}Z` +
+ `,${adjustedEndDate.toISOString().split('.')[0]}Z`,
+ );
+ }
+ }, [startDate, endDate]);
+
+ const open = Boolean(anchorEl);
+ const id = open ? 'date-range-popover' : undefined;
+
+ return (
+ <>
+
+ handleClick(e)}
+ inputProps={{
+ readOnly: true,
+ style: { cursor: 'pointer' },
+ }}
+ />
+
+
+
+
+ >
+ );
+};
+
+export default React.memo(SelectWindow);
diff --git a/plugins/opencost/src/components/Subtitle.jsx b/plugins/opencost/src/components/Subtitle.jsx
new file mode 100644
index 0000000000..63b3df61ff
--- /dev/null
+++ b/plugins/opencost/src/components/Subtitle.jsx
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { makeStyles } from '@material-ui/styles';
+import { upperFirst } from 'lodash';
+import Breadcrumbs from '@material-ui/core/Breadcrumbs';
+import NavigateNextIcon from '@material-ui/icons/NavigateNext';
+import Typography from '@material-ui/core/Typography';
+import { toVerboseTimeRange } from '../util';
+
+const useStyles = makeStyles({
+ root: {
+ '& > * + *': {
+ marginTop: 2,
+ },
+ },
+ link: {
+ cursor: 'pointer',
+ },
+});
+
+const Subtitle = ({ report }) => {
+ const classes = useStyles();
+
+ const { aggregateBy, window } = report;
+
+ return (
+