diff --git a/ngui/ui/src/api/restapi/actionTypes.ts b/ngui/ui/src/api/restapi/actionTypes.ts
index 7f83724cb..1f28fb3c1 100644
--- a/ngui/ui/src/api/restapi/actionTypes.ts
+++ b/ngui/ui/src/api/restapi/actionTypes.ts
@@ -423,3 +423,6 @@ export const DELETE_LAYOUT = "DELETE_LAYOUT";
export const RESTORE_PASSWORD = "RESTORE_PASSWORD";
export const VERIFY_EMAIL = "VERIFY_EMAIL";
+
+export const SET_ORG_DATASOURCES_TAGS = "SET_ORG_DATASOURCES_TAGS";
+export const GET_ORG_DATASOURCES_TAGS = "GET_ORG_DATASOURCES_TAGS";
diff --git a/ngui/ui/src/api/restapi/reducer.ts b/ngui/ui/src/api/restapi/reducer.ts
index 41e889c04..ce70b9af2 100644
--- a/ngui/ui/src/api/restapi/reducer.ts
+++ b/ngui/ui/src/api/restapi/reducer.ts
@@ -117,7 +117,8 @@ import {
SET_ML_ARTIFACT,
SET_ML_DATASET_LABELS,
SET_ML_TASK_TAGS,
- CREATE_ML_LEADERBOARD
+ CREATE_ML_LEADERBOARD,
+ SET_ORG_DATASOURCES_TAGS
} from "./actionTypes";
export const RESTAPI = "restapi";
@@ -149,6 +150,13 @@ const reducer = (state = {}, action) => {
pool: action.payload
}
};
+ case SET_ORG_DATASOURCES_TAGS:
+ return {
+ ...state,
+ [action.label]: {
+ dataSourcesTags: action.payload
+ }
+ };
case DELETE_POOL:
// note: that code does nothing in current UI flow: GET_POOL storage at the moment of pool deletion is _that pool_.
// but if we place "remove button" to any other place (for example, at pools table) — filter will work
diff --git a/ngui/ui/src/components/CloudAccountsTable/CloudAccountsTable.tsx b/ngui/ui/src/components/CloudAccountsTable/CloudAccountsTable.tsx
index 43ea7e427..9b2f3da2c 100644
--- a/ngui/ui/src/components/CloudAccountsTable/CloudAccountsTable.tsx
+++ b/ngui/ui/src/components/CloudAccountsTable/CloudAccountsTable.tsx
@@ -5,6 +5,7 @@ import { Typography } from "@mui/material";
import { useTheme } from "@mui/material/styles";
import { FormattedMessage } from "react-intl";
import { useNavigate } from "react-router-dom";
+import { DataSourceTagCell } from "@theme/shared/components/DataSourceTagCell/DataSourceTagCell";
import CaptionedCell from "components/CaptionedCell";
import Circle from "components/Circle";
import CloudLabel from "components/CloudLabel";
@@ -79,7 +80,6 @@ const NameCell = ({
const CloudAccountsTable = ({ cloudAccounts = [], isLoading = false }) => {
const navigate = useNavigate();
-
const theme = useTheme();
const { classes } = useStyles();
@@ -102,6 +102,14 @@ const CloudAccountsTable = ({ cloudAccounts = [], isLoading = false }) => {
accessorKey: "type",
cell: ({ cell }) =>
},
+ {
+ header: intl.formatMessage({ id: "entitled" }),
+ id: "tags",
+ accessorKey: "id",
+ cell: ({ cell }) => ,
+ enableSorting: false,
+ emptyValue:
+ },
{
header: intl.formatMessage({ id: "resourcesChargedThisMonth" }),
id: "details.resources",
@@ -156,22 +164,27 @@ const CloudAccountsTable = ({ cloudAccounts = [], isLoading = false }) => {
return isLoading ? (
) : (
-
+ <>
+
+
+
+
+ >
);
};
diff --git a/ngui/ui/src/themes/finops/components/CloudAccountsOverview/CloudAccountsOverview.tsx b/ngui/ui/src/themes/finops/components/CloudAccountsOverview/CloudAccountsOverview.tsx
new file mode 100644
index 000000000..ea11c2196
--- /dev/null
+++ b/ngui/ui/src/themes/finops/components/CloudAccountsOverview/CloudAccountsOverview.tsx
@@ -0,0 +1,166 @@
+import { Stack } from "@mui/material";
+import ActionBar from "components/ActionBar";
+import CloudAccountsTable from "components/CloudAccountsTable";
+import CloudExpensesChart from "components/CloudExpensesChart";
+import InlineSeverityAlert from "components/InlineSeverityAlert";
+import PageContentWrapper from "components/PageContentWrapper";
+import SummaryGrid from "components/SummaryGrid";
+import { useIsAllowed } from "hooks/useAllowedActions";
+import { getSumByNestedObjectKey, isEmptyArray } from "utils/arrays";
+import { SUMMARY_VALUE_COMPONENT_TYPES, SUMMARY_CARD_TYPES, AWS_CNR } from "utils/constants";
+import { SPACING_2 } from "utils/layouts";
+import { getPercentageChangeModule, round } from "utils/math";
+
+type SummaryProps = {
+ totalExpenses: number;
+ totalForecast: number;
+ lastMonthCost: number;
+ isLoading?: boolean;
+};
+
+const actionBarDefinition = {
+ title: {
+ messageId: "dataSourcesTitle"
+ }
+};
+
+const Summary = ({ totalExpenses, totalForecast, lastMonthCost, isLoading = false }: SummaryProps) => {
+ const getSummaryData = () =>
+ lastMonthCost
+ ? [
+ {
+ key: "totalExpensesMonthToDate",
+ type: SUMMARY_CARD_TYPES.EXTENDED,
+ valueComponentType: SUMMARY_VALUE_COMPONENT_TYPES.FormattedMoney,
+ valueComponentProps: {
+ value: totalExpenses
+ },
+ captionMessageId: "totalExpensesMonthToDate",
+ relativeValueComponentType: SUMMARY_VALUE_COMPONENT_TYPES.FormattedNumber,
+ relativeValueComponentProps: {
+ value: getPercentageChangeModule(totalExpenses, lastMonthCost) / 100,
+ format: "percentage"
+ },
+ relativeValueCaptionMessageId:
+ totalExpenses > lastMonthCost ? "moreThanForPreviousMonth" : "lessThanForPreviousMonth",
+ dataTestIds: {
+ cardTestId: "card_total_exp"
+ },
+ color: totalExpenses > lastMonthCost ? "error" : "success",
+ isLoading
+ },
+ {
+ key: "forecastForThisMonth",
+ type: SUMMARY_CARD_TYPES.EXTENDED,
+ valueComponentType: SUMMARY_VALUE_COMPONENT_TYPES.FormattedMoney,
+ valueComponentProps: {
+ value: totalForecast
+ },
+ captionMessageId: "forecastForThisMonth",
+ relativeValueComponentType: SUMMARY_VALUE_COMPONENT_TYPES.FormattedNumber,
+ relativeValueComponentProps: {
+ value: getPercentageChangeModule(totalForecast, lastMonthCost) / 100,
+ format: "percentage"
+ },
+ relativeValueCaptionMessageId:
+ totalForecast > lastMonthCost ? "moreThanForPreviousMonth" : "lessThanForPreviousMonth",
+ dataTestIds: {
+ cardTestId: "card_forecast"
+ },
+ color: totalForecast > lastMonthCost ? "error" : "success",
+ isLoading
+ }
+ ]
+ : [
+ {
+ key: "totalExpensesMonthToDate",
+ valueComponentType: SUMMARY_VALUE_COMPONENT_TYPES.FormattedMoney,
+ valueComponentProps: {
+ value: totalExpenses
+ },
+ captionMessageId: "totalExpensesMonthToDate",
+ dataTestIds: {
+ cardTestId: "card_total_exp"
+ },
+ isLoading
+ },
+ {
+ key: "forecastForThisMonth",
+ valueComponentType: SUMMARY_VALUE_COMPONENT_TYPES.FormattedMoney,
+ valueComponentProps: {
+ value: totalForecast
+ },
+ captionMessageId: "forecastForThisMonth",
+ dataTestIds: {
+ cardTestId: "card_forecast"
+ },
+ isLoading
+ }
+ ];
+
+ return ;
+};
+
+const AwsLinkedAccountsWarning = () => {
+ const isManageCloudCredentialsAllowed = useIsAllowed({ requiredActions: ["MANAGE_CLOUD_CREDENTIALS"] });
+
+ return (
+
+ );
+};
+
+const CloudAccountsOverview = ({ cloudAccounts = [], organizationLimit, isLoading = false }) => {
+ if (isEmptyArray(cloudAccounts)) {
+ return <>>;
+ }
+ const totalExpenses = round(getSumByNestedObjectKey(cloudAccounts, "details", "cost"), 2);
+ const totalForecast = round(getSumByNestedObjectKey(cloudAccounts, "details", "forecast"), 2);
+ const lastMonthCost = round(getSumByNestedObjectKey(cloudAccounts, "details", "last_month_cost"), 2);
+
+ const awsDataSources = cloudAccounts.filter(({ type }) => type === AWS_CNR);
+ const onlyAwsLinkedAccountsConnected = !isEmptyArray(awsDataSources) && awsDataSources.every(({ config }) => config?.linked);
+
+ return (
+ <>
+
+
+
+
+
+
+
+ {(organizationLimit === 0 && totalForecast === 0) || totalExpenses === 0 ? null : (
+
+ )}
+
+ {!isLoading && onlyAwsLinkedAccountsConnected && (
+
+ )}
+
+
+
+
+
+ >
+ );
+};
+
+export default CloudAccountsOverview;
diff --git a/ngui/ui/src/themes/finops/components/CloudAccountsOverview/index.ts b/ngui/ui/src/themes/finops/components/CloudAccountsOverview/index.ts
new file mode 100644
index 000000000..74035d54b
--- /dev/null
+++ b/ngui/ui/src/themes/finops/components/CloudAccountsOverview/index.ts
@@ -0,0 +1,5 @@
+import CloudAccountsOverview from "./CloudAccountsOverview";
+import CloudAccountsOverviewMocked from "./CloudAccountsOverviewMocked";
+
+export default CloudAccountsOverview;
+export { CloudAccountsOverviewMocked };
diff --git a/ngui/ui/src/themes/finops/containers/GetCloudAccountsContainer/GetCloudAccountsContainer.tsx b/ngui/ui/src/themes/finops/containers/GetCloudAccountsContainer/GetCloudAccountsContainer.tsx
new file mode 100644
index 000000000..83b7b13d7
--- /dev/null
+++ b/ngui/ui/src/themes/finops/containers/GetCloudAccountsContainer/GetCloudAccountsContainer.tsx
@@ -0,0 +1,44 @@
+import { useEffect } from "react";
+import { useDispatch } from "react-redux";
+import { useDataSourceTagsByOrg } from "@theme/shared/hooks/useDataSourceTagsByOrg";
+import { getPool } from "api";
+import { GET_POOL } from "api/restapi/actionTypes";
+import CloudAccountsOverview from "components/CloudAccountsOverview";
+import { useAllDataSources } from "hooks/coreData/useAllDataSources";
+import { useApiData } from "hooks/useApiData";
+import { useApiState } from "hooks/useApiState";
+import { useOrganizationInfo } from "hooks/useOrganizationInfo";
+
+const GetCloudAccountsContainer = () => {
+ const { organizationId } = useOrganizationInfo();
+ const { isLoading: isTagsLoading } = useDataSourceTagsByOrg(organizationId);
+ const dataSources = useAllDataSources();
+
+ const { organizationPoolId } = useOrganizationInfo();
+
+ const dispatch = useDispatch();
+
+ const {
+ apiData: { pool: { limit: organizationLimit = 0 } = {} }
+ } = useApiData(GET_POOL);
+
+ const { isLoading: isGetPoolLoading, shouldInvoke: shouldInvokeGetPool } = useApiState(GET_POOL, {
+ poolId: organizationPoolId
+ });
+
+ useEffect(() => {
+ if (organizationPoolId && shouldInvokeGetPool) {
+ dispatch(getPool(organizationPoolId));
+ }
+ }, [shouldInvokeGetPool, dispatch, organizationPoolId]);
+
+ return (
+
+ );
+};
+
+export default GetCloudAccountsContainer;
diff --git a/ngui/ui/src/themes/finops/containers/GetCloudAccountsContainer/index.ts b/ngui/ui/src/themes/finops/containers/GetCloudAccountsContainer/index.ts
new file mode 100644
index 000000000..16e99154d
--- /dev/null
+++ b/ngui/ui/src/themes/finops/containers/GetCloudAccountsContainer/index.ts
@@ -0,0 +1,3 @@
+import GetCloudAccountsContainer from "./GetCloudAccountsContainer";
+
+export default GetCloudAccountsContainer;
diff --git a/ngui/ui/src/themes/finops/shared/components/DataSourceTagCell/DataSourceTagCell.styles.ts b/ngui/ui/src/themes/finops/shared/components/DataSourceTagCell/DataSourceTagCell.styles.ts
new file mode 100644
index 000000000..93ff988f4
--- /dev/null
+++ b/ngui/ui/src/themes/finops/shared/components/DataSourceTagCell/DataSourceTagCell.styles.ts
@@ -0,0 +1,21 @@
+import { Theme } from "@mui/material";
+import { makeStyles } from "tss-react/mui";
+import { MPT_SPACING_2 } from "@theme/utils/layouts";
+const useStyles = makeStyles()((theme: Theme) => ({
+ icon: {
+ fontSize: MPT_SPACING_2,
+ "& > svg": {
+ paddingTop: "2px",
+ width: "0.8rem",
+ height: "0.8rem"
+ }
+ },
+ labelSuccess: {
+ color: theme.palette.success.main
+ },
+ tagValue: {
+ color: theme.palette.primary.gray4
+ }
+}));
+
+export default useStyles;
diff --git a/ngui/ui/src/themes/finops/shared/components/DataSourceTagCell/DataSourceTagCell.tsx b/ngui/ui/src/themes/finops/shared/components/DataSourceTagCell/DataSourceTagCell.tsx
new file mode 100644
index 000000000..05a344c07
--- /dev/null
+++ b/ngui/ui/src/themes/finops/shared/components/DataSourceTagCell/DataSourceTagCell.tsx
@@ -0,0 +1,54 @@
+import CheckIcon from "@mui/icons-material/Check";
+import ClearIcon from "@mui/icons-material/Clear";
+import Typography from "@mui/material/Typography/Typography";
+import { FormattedMessage } from "react-intl";
+import { GET_ORG_DATASOURCES_TAGS } from "@main/api/restapi/actionTypes";
+import IconLabel from "@main/components/IconLabel";
+import { useApiData } from "@main/hooks/useApiData";
+import { DataSourceTag } from "@theme/shared/hooks/useDataSourceTagsByOrg";
+import useStyles from "./DataSourceTagCell.styles";
+
+export const DataSourceTagCell = ({ dataSourceId, tagKey }: { dataSourceId: string; tagKey: string }) => {
+ const { apiData: { dataSourcesTags: tags } = {} } = useApiData(GET_ORG_DATASOURCES_TAGS);
+ const { classes } = useStyles();
+
+ if (!tags) {
+ return ;
+ }
+
+ const tag = tags[dataSourceId]?.find((tag: DataSourceTag) => tag.name === tagKey);
+ const labelClass = tag ? classes.labelSuccess : "";
+
+ return (
+ <>
+
+ {tag ? : }
+
+ }
+ display="inline-flex"
+ label={
+ <>
+
+
+
+
+
+
+ {tag && tag.value && (
+
+
+ {tag.value}
+
+
+ )}
+
+ >
+ }
+ />
+ >
+ );
+};
+
+export default DataSourceTagCell;
diff --git a/ngui/ui/src/themes/finops/shared/hooks/useDataSourceTagsByOrg.tsx b/ngui/ui/src/themes/finops/shared/hooks/useDataSourceTagsByOrg.tsx
new file mode 100644
index 000000000..3d9b90e30
--- /dev/null
+++ b/ngui/ui/src/themes/finops/shared/hooks/useDataSourceTagsByOrg.tsx
@@ -0,0 +1,62 @@
+import { useEffect } from "react";
+import { useDispatch } from "react-redux/es/hooks/useDispatch";
+import { handleSuccess } from "@main/api/actionCreators";
+import { MINUTE } from "@main/api/constants";
+import { GET_ORG_DATASOURCES_TAGS, SET_ORG_DATASOURCES_TAGS } from "@main/api/restapi/actionTypes";
+import { apiAction, getApiUrl, hashParams } from "@main/api/utils";
+import { useApiData } from "@main/hooks/useApiData";
+import { useApiState } from "@main/hooks/useApiState";
+
+export type DataSourceTag = {
+ name: string;
+ value: string;
+};
+
+type DataSource = {
+ id: string;
+ tags: DataSourceTag[];
+};
+
+const API_URL = getApiUrl("ffc", "v1");
+
+const getDataSourcesTags = (organizationId: string) =>
+ apiAction({
+ url: `${API_URL}/client/organizations/${organizationId}/datasources`,
+ method: "GET",
+ onSuccess: (data, label) => {
+ const mappedData = data.reduce(
+ (acc: Record, item: DataSource) => {
+ const { id, tags } = item;
+ acc[id] = tags;
+
+ return acc;
+ },
+ {} as Record
+ );
+
+ return handleSuccess(SET_ORG_DATASOURCES_TAGS)(mappedData, label);
+ },
+ label: GET_ORG_DATASOURCES_TAGS,
+ ttl: 30 * MINUTE,
+ hash: hashParams({ organizationId })
+ });
+
+export const useDataSourceTagsByOrg = (organizationId: string) => {
+ const dispatch = useDispatch();
+ const { apiData: { dataSourcesTags: tags } = {} } = useApiData(GET_ORG_DATASOURCES_TAGS);
+ const { isLoading, shouldInvoke } = useApiState(GET_ORG_DATASOURCES_TAGS, {
+ organizationId
+ });
+
+ useEffect(() => {
+ if (organizationId && shouldInvoke) {
+ dispatch(getDataSourcesTags(organizationId));
+ }
+ }, [dispatch, organizationId, shouldInvoke]);
+
+ return {
+ isLoading,
+ shouldInvoke,
+ tags
+ };
+};
diff --git a/ngui/ui/src/themes/finops/theme.ts b/ngui/ui/src/themes/finops/theme.ts
index 910cc7806..0f7a2f5c1 100644
--- a/ngui/ui/src/themes/finops/theme.ts
+++ b/ngui/ui/src/themes/finops/theme.ts
@@ -53,6 +53,7 @@ const applyPaletteSettings = (settings) => {
main: MPT_BRAND_PRIMARY,
white: MPT_BRAND_WHITE,
gray2: MPT_GRAY_2,
+ gray4: MPT_GRAY_4,
gradient: MPT_GRADIENT,
card: MPT_BRAND_TYPE
},
diff --git a/ngui/ui/src/translations/en-US/app.json b/ngui/ui/src/translations/en-US/app.json
index d085929d5..901dc43eb 100644
--- a/ngui/ui/src/translations/en-US/app.json
+++ b/ngui/ui/src/translations/en-US/app.json
@@ -743,6 +743,8 @@
"enterNewPasswordToResetAccountPassword": "Enter a new password to reset your account password.",
"enterprise": "Enterprise",
"entitiesMustBeUnique": "{name} must be unique",
+ "entitled": "Entitled *",
+ "entitledColumnDisclaimer": "* Note: Entitled data sources are included with your SoftwareOne Service(s) and will incur no additional charge. Data sources without an entitlement will be invoiced at a percentage of your spend for that data source.",
"entity:name": "{entity} name",
"environment": "Environment",
"environmentAcquireReleaseHooks": "Shared Environment acquire/release hooks",
@@ -1161,6 +1163,7 @@
"liveDemo": "Live demo",
"liveDemoDisclaimer": "Due to high demand, live demos are limited to one per email daily.{br}Thanks for your understanding, and enjoy the live demo!",
"liveDemoMode": "You are in a live demo mode",
+ "loading": "Loading",
"loadingResourceData": "Loading resources data",
"localStorageBottleneck": "Local storage bottleneck",
"localStorageBottleneckDescription": "The task has stages with intensive local disk I/O. Please consider more performant disks or code optimization to avoid bottlenecks.",