+
+The CloudForecast Plugin for Cortex shows current highlights from CloudForecast reports related to your Cortex entities, and deeplinks back to the full report in CloudForecast. The data is ingested from CloudForecast webhooks using a Cortex Custom Integration.
+
+## Setup
+
+This plugin is available from the Cortex Plugin Marketplace. If you want to build the plugin yourself, follow the steps under **Build the Plugin** at the end of this document.
+
+### Cortex Setup
+
+This plugin uses a Custom Integration to take in webhooks from CloudForecast. To set up the Custom Integration in Cortex, follow these steps:
+
+- Click on your user icon on the bottom left, then click Settings
+- Click on Custom Integrations under the Integrations heading
+- Under the New Custom Integration heading, fill in the three fields as below:
+ - Name: `CloudForecast`
+ - Entity Tag JQ: `.entityTag`
+ - Key: `cloudforecast`
+- Click Save.
+
+Once you are done, your Custom Integration should look like this:
+
+
+
+Copy the URL that's shown under the key. This will be the integration URL that you add to CloudForecast.
+
+### CloudForecast Basic Setup
+
+- In CloudForecast, click on Settings > Cortex
+- Paste in the Custom Integration URL that you copied from the **Cortex Setup** above
+- Click on Save Changes
+
+
+
+### CloudForecast Report Setup
+
+- Click on Reports > Cost Groups
+- Click the Configure button on a Cost Group that you want to send to a Cortex entity
+- Under "How would you like to receive your reports?" click on Cortex
+- Type in the tag of the entity where you want this report to appear, and click on Save Changes
+- Repeat these steps for all the Cost Groups you want to link to Cortex
+
+
+
+_If you don't see the Cortex button in CloudForecast, reach out to your CloudForecast support team to enable it._
+
+After the webhooks are delivered from Cortex, you will be able to see your CloudForecast data in your Entity Details page in Cortex, under the Plugins section!
+
+### Build the Plugin (optional)
+
+- Build the plugin:
+- Make sure you have npm or yarn.
+- In your terminal, in the `cloudforecast` directory, type `yarn` or `npm install` to install the dependencies; then type `npm run build` or `yarn build` to build the plugin.
+- The compiled plugin will be created in `dist/ui.html`.
+- In Plugins > All, click **Register Plugin**.
+- Give the plugin a name, like CloudForecast. This is the name users will see in the plugin listing.
+- Under **Plugin Context**, click on Add another context; choose Selection type: Include, and Entity types: service.
+- This plugin does not work in the Global context. Turn off the switch labeled **Include in global context**.
+- In The **Plugin code** section, upload the `dist/ui.html` file you just built.
+- Click on **Save plugin**.
+
+# Setting up your dev environment
+
+SonarQube Issues Cortex Plugin is a [Cortex](https://www.cortex.io/) plugin. To see how to run the plugin inside of Cortex, see [our docs](https://docs.cortex.io/docs/plugins).
+
+### Prerequisites
+
+Developing and building this plugin requires either [yarn](https://classic.yarnpkg.com/lang/en/docs/install/) or [npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm).
+
+## Getting started
+
+1. Run `yarn` or `npm install` to download all dependencies
+2. Run `yarn build` or `npm run build` to compile the plugin code into `./dist/ui.html`
+3. Upload `ui.html` into Cortex on a create or edit plugin page
+4. Add or update the code and repeat steps 2-3 as necessary
+
+### Notable scripts
+
+The following commands come pre-configured in this repository. You can see all available commands in the `scripts` section of [package.json](./package.json). They can be run with npm via `npm run {script_name}` or with yarn via `yarn {script_name}`, depending on your package manager preference. For instance, the `build` command can be run with `npm run build` or `yarn build`.
+
+- `build` - compiles the plugin. The compiled code root is `./src/index.tsx` (or as defined by [webpack.config.js](webpack.config.js)) and the output is generated into `dist/ui.html`.
+- `test` - runs all tests defined in the repository using [jest](https://jestjs.io/)
+- `lint` - runs lint and format checking on the repository using [prettier](https://prettier.io/) and [eslint](https://eslint.org/)
+- `lintfix` - runs eslint in fix mode to fix any linting errors that can be fixed automatically
+- `formatfix` - runs Prettier in fix mode to fix any formatting errors that can be fixed automatically
+
+### Available React components
+
+See available UI components via our [Storybook](https://cortexapps.github.io/plugin-core/).
diff --git a/plugins/cloudforecast/__mocks__/fileMock.js b/plugins/cloudforecast/__mocks__/fileMock.js
new file mode 100644
index 0000000..0a445d0
--- /dev/null
+++ b/plugins/cloudforecast/__mocks__/fileMock.js
@@ -0,0 +1 @@
+module.exports = "test-file-stub";
diff --git a/plugins/cloudforecast/__mocks__/styleMock.js b/plugins/cloudforecast/__mocks__/styleMock.js
new file mode 100644
index 0000000..f053ebf
--- /dev/null
+++ b/plugins/cloudforecast/__mocks__/styleMock.js
@@ -0,0 +1 @@
+module.exports = {};
diff --git a/plugins/cloudforecast/babel.config.js b/plugins/cloudforecast/babel.config.js
new file mode 100644
index 0000000..1442fdf
--- /dev/null
+++ b/plugins/cloudforecast/babel.config.js
@@ -0,0 +1,8 @@
+module.exports = {
+ plugins: ["@babel/plugin-syntax-jsx"],
+ presets: [
+ ["@babel/preset-env", { targets: { node: "current" } }],
+ "@babel/preset-typescript",
+ ["@babel/preset-react", { runtime: "automatic" }],
+ ],
+};
diff --git a/plugins/cloudforecast/img/cf_ss1.png b/plugins/cloudforecast/img/cf_ss1.png
new file mode 100644
index 0000000..e6de0af
Binary files /dev/null and b/plugins/cloudforecast/img/cf_ss1.png differ
diff --git a/plugins/cloudforecast/img/cf_ss2.png b/plugins/cloudforecast/img/cf_ss2.png
new file mode 100644
index 0000000..8530801
Binary files /dev/null and b/plugins/cloudforecast/img/cf_ss2.png differ
diff --git a/plugins/cloudforecast/img/cf_ss3.jpg b/plugins/cloudforecast/img/cf_ss3.jpg
new file mode 100644
index 0000000..48cc9f3
Binary files /dev/null and b/plugins/cloudforecast/img/cf_ss3.jpg differ
diff --git a/plugins/cloudforecast/img/cf_ss4.jpg b/plugins/cloudforecast/img/cf_ss4.jpg
new file mode 100644
index 0000000..a52687a
Binary files /dev/null and b/plugins/cloudforecast/img/cf_ss4.jpg differ
diff --git a/plugins/cloudforecast/jest.config.js b/plugins/cloudforecast/jest.config.js
new file mode 100644
index 0000000..e7cc6d7
--- /dev/null
+++ b/plugins/cloudforecast/jest.config.js
@@ -0,0 +1,18 @@
+module.exports = {
+ moduleNameMapper: {
+ // map static asset imports to a stub file under the assumption they are not important to our tests
+ "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$":
+ "/__mocks__/fileMock.js",
+ // map style asset imports to a stub file under the assumption they are not important to our tests
+ "\\.(css|less)$": "/__mocks__/styleMock.js",
+ "@cortexapps/plugin-core/components":
+ "/../../node_modules/@cortexapps/plugin-core/dist/components.cjs.js",
+ "@cortexapps/plugin-core":
+ "/../../node_modules/@cortexapps/plugin-core/dist/index.cjs.js",
+ },
+ setupFilesAfterEnv: ["/setupTests.ts"],
+ testEnvironment: "jsdom",
+ transform: {
+ "^.+\\.tsx?$": "babel-jest",
+ },
+};
diff --git a/plugins/cloudforecast/package.json b/plugins/cloudforecast/package.json
new file mode 100644
index 0000000..86fec46
--- /dev/null
+++ b/plugins/cloudforecast/package.json
@@ -0,0 +1,68 @@
+{
+ "name": "cloudforecast-plugin",
+ "version": "0.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "@chakra-ui/icons": "^2.2.4",
+ "@chakra-ui/react": "2",
+ "@cortexapps/plugin-core": "^2.0.0",
+ "@emotion/react": "^11.13.3",
+ "@emotion/styled": "^11.13.0",
+ "framer-motion": "^11.11.17",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-icons": "^5.3.0"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.21.3",
+ "@babel/plugin-syntax-jsx": "^7.18.6",
+ "@babel/preset-env": "^7.20.2",
+ "@babel/preset-react": "^7.18.6",
+ "@babel/preset-typescript": "^7.21.0",
+ "@popperjs/core": "^2.11.8",
+ "@testing-library/jest-dom": "^5.17.0",
+ "@testing-library/react": "^14.0.0",
+ "@types/react": "^18.0.28",
+ "@types/react-dom": "^18.0.11",
+ "@typescript-eslint/eslint-plugin": "^5.0.0",
+ "@typescript-eslint/parser": "^5.55.0",
+ "babel-jest": "^29.5.0",
+ "css-loader": "^6.7.3",
+ "eslint": "^8.0.1",
+ "eslint-config-prettier": "^8.7.0",
+ "eslint-config-standard-with-typescript": "^34.0.0",
+ "eslint-plugin-import": "^2.25.2",
+ "eslint-plugin-n": "^15.6.1",
+ "eslint-plugin-promise": "^6.0.0",
+ "eslint-plugin-react": "^7.32.2",
+ "eslint-plugin-react-hooks": "^4.6.0",
+ "html-webpack-plugin": "^5.5.0",
+ "jest": "^29.5.0",
+ "jest-environment-jsdom": "^29.5.0",
+ "jest-fetch-mock": "^3.0.3",
+ "npm-run-all": "^4.1.5",
+ "prettier": "^2.8.4",
+ "prop-types": "^15.8.1",
+ "react-dev-utils": "^12.0.1",
+ "style-loader": "^3.3.1",
+ "terser-webpack-plugin": "^5.3.7",
+ "ts-loader": "^9.4.2",
+ "typescript": "^4.9.5",
+ "url-loader": "^4.1.1",
+ "webpack": "^5.76.1",
+ "webpack-cli": "^5.0.1",
+ "webpack-dev-server": "^4.15.0"
+ },
+ "scripts": {
+ "build": "webpack --mode=production",
+ "clean": "rm -r ./dist",
+ "dev": "webpack serve --mode=development",
+ "fix": "run-p formatfix lintfix",
+ "formatfix": "yarn prettier . --write",
+ "formatcheck": "yarn prettier . --check",
+ "lint": "run-p formatcheck lintcheck",
+ "lintcheck": "yarn eslint src",
+ "lintfix": "yarn lintcheck --fix",
+ "test": "jest"
+ }
+}
diff --git a/plugins/cloudforecast/setupTests.ts b/plugins/cloudforecast/setupTests.ts
new file mode 100644
index 0000000..bab11da
--- /dev/null
+++ b/plugins/cloudforecast/setupTests.ts
@@ -0,0 +1,60 @@
+import "@testing-library/jest-dom/extend-expect";
+import fetchMock from "jest-fetch-mock";
+
+fetchMock.enableMocks();
+
+const mockContext = {
+ apiBaseUrl: "https://api.cortex.dev",
+ entity: {
+ definition: null,
+ description: null,
+ groups: null,
+ name: "Inventory planner",
+ ownership: {
+ emails: [
+ {
+ description: null,
+ email: "nikhil@cortex.io",
+ inheritance: null,
+ id: 1,
+ },
+ ],
+ },
+ tag: "inventory-planner",
+ type: "service",
+ },
+ location: "ENTITY",
+ user: {
+ email: "ganesh@cortex.io",
+ name: "Ganesh Datta",
+ role: "ADMIN",
+ },
+};
+
+jest.mock("@cortexapps/plugin-core/components", () => {
+ const originalModule = jest.requireActual(
+ "@cortexapps/plugin-core/components"
+ );
+ return {
+ ...originalModule,
+ usePluginContext: () => {
+ return mockContext;
+ },
+ PluginProvider: ({ children }) => {
+ return children;
+ },
+ };
+});
+
+jest.mock("@cortexapps/plugin-core", () => {
+ const originalModule = jest.requireActual("@cortexapps/plugin-core");
+ return {
+ ...originalModule,
+ CortexApi: {
+ ...originalModule.CortexApi,
+ getContext: () => {
+ return mockContext;
+ },
+ },
+ };
+});
diff --git a/plugins/cloudforecast/src/api/Cortex.ts b/plugins/cloudforecast/src/api/Cortex.ts
new file mode 100644
index 0000000..2f72486
--- /dev/null
+++ b/plugins/cloudforecast/src/api/Cortex.ts
@@ -0,0 +1,8 @@
+export const getEntityYaml = async (
+ baseUrl: string,
+ entityTag: string
+): Promise => {
+ const res = await fetch(`${baseUrl}/catalog/${entityTag}/openapi`);
+
+ return await res.json();
+};
diff --git a/plugins/cloudforecast/src/baseStyles.css b/plugins/cloudforecast/src/baseStyles.css
new file mode 100644
index 0000000..e9c5e5f
--- /dev/null
+++ b/plugins/cloudforecast/src/baseStyles.css
@@ -0,0 +1,3 @@
+body {
+ font: 14px sans-serif;
+}
diff --git a/plugins/cloudforecast/src/cloudForecastSchema.ts b/plugins/cloudforecast/src/cloudForecastSchema.ts
new file mode 100644
index 0000000..c9deda7
--- /dev/null
+++ b/plugins/cloudforecast/src/cloudForecastSchema.ts
@@ -0,0 +1,103 @@
+export interface CloudForecastData {
+ entityTag: string;
+ generatedAt: string; // ISO date-time format
+ dataFormatVersion?: string;
+ links: CloudForecastLinks;
+ dailyMetrics: CloudForecastDailyMetrics;
+ monthlyMetrics: CloudForecastMonthlyMetrics;
+ recentAlerts?: CloudForecastRecentAlert[];
+}
+
+export interface CloudForecastLinks {
+ mostRecentReportDeepLink: string;
+}
+
+export interface CloudForecastDailyMetrics {
+ mostRecentDay: CloudForecastDayMetrics;
+ previousDay?: CloudForecastDayMetrics;
+ sevenDayAverage?: number;
+ thirtyDayAverage?: number;
+}
+
+export interface CloudForecastDayMetrics {
+ cost: number;
+ dayAsStr: string; // Matches YYYY-MM-DD
+}
+
+export interface CloudForecastMonthlyMetrics {
+ currentMonth: CloudForecastMonthMetrics;
+ previousMonth?: CloudForecastMonthMetrics;
+ endOfMonthForecast: number;
+ monthlyBudget?: number;
+}
+
+export interface CloudForecastMonthMetrics {
+ cost: number;
+ monthAsStr: string; // Matches YYYY-MM
+}
+
+export interface CloudForecastRecentAlert {
+ report_date?: string; // Matches YYYY-MM-DD
+ description?: string;
+ whyDeepLink?: string;
+ status?: "cloudy" | "stormy";
+}
+
+export const isCloudForecastData = (data: any): data is CloudForecastData => {
+ const isString = (value: any): boolean => typeof value === "string";
+ const isNumber = (value: any): boolean => typeof value === "number";
+
+ const isCloudForecastDayMetrics = (
+ metrics: any
+ ): metrics is CloudForecastDayMetrics =>
+ metrics && isNumber(metrics.cost) && isString(metrics.dayAsStr);
+
+ const isCloudForecastDailyMetrics = (
+ metrics: any
+ ): metrics is CloudForecastDailyMetrics =>
+ metrics &&
+ isCloudForecastDayMetrics(metrics.mostRecentDay) &&
+ (!metrics.previousDay || isCloudForecastDayMetrics(metrics.previousDay)) &&
+ (metrics.sevenDayAverage === undefined ||
+ isNumber(metrics.sevenDayAverage)) &&
+ (metrics.thirtyDayAverage === undefined ||
+ isNumber(metrics.thirtyDayAverage));
+
+ const isCloudForecastMonthMetrics = (
+ metrics: any
+ ): metrics is CloudForecastMonthMetrics =>
+ metrics && isNumber(metrics.cost) && isString(metrics.monthAsStr);
+
+ const isCloudForecastMonthlyMetrics = (
+ metrics: any
+ ): metrics is CloudForecastMonthlyMetrics =>
+ metrics &&
+ isCloudForecastMonthMetrics(metrics.currentMonth) &&
+ (!metrics.previousMonth ||
+ isCloudForecastMonthMetrics(metrics.previousMonth)) &&
+ isNumber(metrics.endOfMonthForecast) &&
+ (metrics.monthlyBudget === undefined || isNumber(metrics.monthlyBudget));
+
+ const isCloudForecastRecentAlert = (
+ alert: any
+ ): alert is CloudForecastRecentAlert =>
+ alert &&
+ (alert.report_date === undefined || isString(alert.report_date)) &&
+ (alert.description === undefined || isString(alert.description)) &&
+ (alert.whyDeepLink === undefined || isString(alert.whyDeepLink)) &&
+ (alert.status === undefined || isString(alert.status));
+
+ return (
+ typeof data === "object" &&
+ data !== null &&
+ isString(data.entityTag) &&
+ isString(data.generatedAt) &&
+ (data.dataFormatVersion === undefined ||
+ isString(data.dataFormatVersion)) &&
+ isCloudForecastDailyMetrics(data.dailyMetrics) &&
+ isCloudForecastMonthlyMetrics(data.monthlyMetrics) &&
+ (data.recentAlerts === undefined ||
+ (Array.isArray(data.recentAlerts) &&
+ data.recentAlerts.every(isCloudForecastRecentAlert)))
+ );
+};
diff --git a/plugins/cloudforecast/src/components/AlertCard.tsx b/plugins/cloudforecast/src/components/AlertCard.tsx
new file mode 100644
index 0000000..e78b4db
--- /dev/null
+++ b/plugins/cloudforecast/src/components/AlertCard.tsx
@@ -0,0 +1,82 @@
+import type React from "react";
+import { Box, Text, HStack, Link, Icon } from "@chakra-ui/react";
+import { ExternalLinkIcon } from "@chakra-ui/icons";
+import {
+ WiRainWind as StormyIcon,
+ WiCloudy as CloudyIcon,
+} from "react-icons/wi";
+import { PiAlien } from "react-icons/pi";
+
+import { type CloudForecastRecentAlert } from "../cloudForecastSchema";
+
+// Custom AlertIcon Component
+const AlertIcon: React.FC<{ status: string }> = ({ status }) => {
+ if (status === "stormy") {
+ return ;
+ } else if (status === "cloudy") {
+ return ;
+ } else {
+ return ;
+ }
+};
+
+// AlertCard Props Interface
+interface AlertCardProps {
+ alert: CloudForecastRecentAlert;
+}
+
+// AlertCard Component
+const AlertCard: React.FC = ({ alert }) => {
+ const { description, status, whyDeepLink } = alert;
+
+ return (
+
+ {/* Left Section: Icon + Description */}
+
+
+
+ {description ?? "No Title"}
+
+
+
+ {/* Right Section: Why? Link */}
+ {whyDeepLink && (
+
+ Why?
+
+ )}
+
+ );
+};
+
+export default AlertCard;
diff --git a/plugins/cloudforecast/src/components/App.test.tsx b/plugins/cloudforecast/src/components/App.test.tsx
new file mode 100644
index 0000000..5927d6b
--- /dev/null
+++ b/plugins/cloudforecast/src/components/App.test.tsx
@@ -0,0 +1,104 @@
+import { render, waitFor } from "@testing-library/react";
+import App from "./App";
+import fetchMock from "jest-fetch-mock";
+
+describe("App", () => {
+ beforeEach(() => {
+ fetchMock.enableMocks();
+ fetchMock.resetMocks();
+ });
+
+ it("gets cost data", async () => {
+ fetchMock.mockResponses(
+ [JSON.stringify({}), { status: 200 }],
+ [
+ JSON.stringify([
+ {
+ key: "cloudforecast",
+ value: {
+ entityTag: "inventory-planner",
+ generatedAt: "2024-11-18T14:35:00Z",
+ dataFormatVersion: "v1.1",
+ links: {
+ mostRecentReportDeepLink:
+ "https://example.com/reports/funrepo/latest",
+ },
+ dailyMetrics: {
+ mostRecentDay: {
+ cost: 120.55,
+ dayAsStr: "2024-11-18",
+ },
+ previousDay: {
+ cost: 110.75,
+ dayAsStr: "2024-11-17",
+ },
+ sevenDayAverage: 115.25,
+ thirtyDayAverage: 112.8,
+ },
+ monthlyMetrics: {
+ currentMonth: {
+ cost: 2340.0,
+ monthAsStr: "2024-11",
+ },
+ previousMonth: {
+ cost: 3100.5,
+ monthAsStr: "2024-10",
+ },
+ endOfMonthForecast: 3650.0,
+ monthlyBudget: 4000.0,
+ },
+ recentAlerts: [
+ {
+ report_date: "2024-11-17",
+ description: "Cost exceeded daily limit.",
+ whyDeepLink: "https://example.com/alerts/funrepo/why",
+ status: "stormy",
+ },
+ ],
+ },
+ },
+ ]),
+ { status: 200 },
+ ]
+ );
+ const { queryByText } = render();
+ await waitFor(() => {
+ expect(fetch).toHaveBeenCalledTimes(2);
+ expect(queryByText("Cost exceeded daily limit.")).toBeInTheDocument();
+ });
+ });
+
+ it("gets no cost data", async () => {
+ fetchMock.mockResponses(
+ [JSON.stringify({}), { status: 200 }],
+ [
+ JSON.stringify([
+ {
+ key: "cloudforecast",
+ },
+ ]),
+ { status: 200 },
+ ]
+ );
+ const { queryByText } = render();
+ await waitFor(() => {
+ expect(fetch).toHaveBeenCalledTimes(2);
+ expect(queryByText("Data not found")).toBeInTheDocument();
+ });
+ });
+
+ it("gets invalid custom data", async () => {
+ fetchMock.mockResponses(
+ [JSON.stringify({}), { status: 200 }],
+ [
+ JSON.stringify([{ key: "cloudforecast", value: "invalid" }]),
+ { status: 200 },
+ ]
+ );
+ const { queryByText } = render();
+ await waitFor(() => {
+ expect(fetch).toHaveBeenCalledTimes(2);
+ expect(queryByText("Invalid data format")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/plugins/cloudforecast/src/components/App.tsx b/plugins/cloudforecast/src/components/App.tsx
new file mode 100644
index 0000000..57d83bb
--- /dev/null
+++ b/plugins/cloudforecast/src/components/App.tsx
@@ -0,0 +1,21 @@
+import type React from "react";
+import { ChakraProvider } from "@chakra-ui/react";
+import { PluginProvider } from "@cortexapps/plugin-core/components";
+import "../baseStyles.css";
+import ErrorBoundary from "./ErrorBoundary";
+
+import CloudForecast from "./CloudForecast";
+
+const App: React.FC = () => {
+ return (
+
+
+
+
+
+
+
+ );
+};
+
+export default App;
diff --git a/plugins/cloudforecast/src/components/CloudForecast.tsx b/plugins/cloudforecast/src/components/CloudForecast.tsx
new file mode 100644
index 0000000..1017680
--- /dev/null
+++ b/plugins/cloudforecast/src/components/CloudForecast.tsx
@@ -0,0 +1,139 @@
+import type React from "react";
+import { Box, Text, Grid, VStack, Link } from "@chakra-ui/react";
+import { ExternalLinkIcon } from "@chakra-ui/icons";
+
+import { Loader } from "@cortexapps/plugin-core/components";
+
+import { formatNumberAsCurrency } from "../utils";
+import { useEntityCloudForecastData } from "../hooks";
+
+import ErrorComponent from "./ErrorComponent";
+import AlertCard from "./AlertCard";
+import UsageCard from "./UsageCard";
+import MonthlyCard from "./MonthlyCard";
+
+// Main Forecast Component
+const CloudForecast: React.FC = () => {
+ const {
+ isLoading,
+ error,
+ cloudForecastData: data,
+ } = useEntityCloudForecastData();
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (error) {
+ return ;
+ }
+
+ if (!data) {
+ return (
+
+ );
+ }
+
+ const { links, entityTag, dailyMetrics, monthlyMetrics, recentAlerts } = data;
+
+ return (
+
+ {/* Header */}
+
+
+ {entityTag} - CloudForecast
+
+
+ ⚡ {formatNumberAsCurrency(dailyMetrics.mostRecentDay.cost)} Daily
+ Spend
+
+
+
+ {/* Forecast Alerts */}
+ {recentAlerts && recentAlerts?.length > 0 && (
+
+
+ Forecast Alerts
+
+
+ {recentAlerts.map((alert, index) => (
+
+ ))}
+
+
+ )}
+
+ {/* Daily Usage */}
+
+
+ Daily Usage
+
+
+ {dailyMetrics?.previousDay && (
+
+ )}
+
+
+
+
+
+
+ {/* Monthly Data */}
+
+
+ Monthly Data
+
+
+
+ {monthlyMetrics?.previousMonth && (
+
+ )}
+
+
+
+ );
+};
+
+export default CloudForecast;
diff --git a/plugins/cloudforecast/src/components/ErrorBoundary.tsx b/plugins/cloudforecast/src/components/ErrorBoundary.tsx
new file mode 100644
index 0000000..f862bd3
--- /dev/null
+++ b/plugins/cloudforecast/src/components/ErrorBoundary.tsx
@@ -0,0 +1,39 @@
+import React from "react";
+
+interface ErrorBoundaryProps extends React.PropsWithChildren {}
+
+interface ErrorBoundaryState {
+ hasError: boolean;
+}
+
+class ErrorBoundary extends React.Component<
+ ErrorBoundaryProps,
+ ErrorBoundaryState
+> {
+ public state: ErrorBoundaryState = {
+ hasError: false,
+ };
+
+ public static getDerivedStateFromError(_: Error): ErrorBoundaryState {
+ // Update state so the next render will show the fallback UI.
+ return { hasError: true };
+ }
+
+ public componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
+ console.error("Uncaught error:", error, errorInfo);
+ }
+
+ public render(): React.ReactNode {
+ if (this.state.hasError) {
+ return (
+
+ Oops! There was a runtime error. See the console for more details.
+