From 56b5b544499ef5a9867156c38c373271ef222626 Mon Sep 17 00:00:00 2001 From: michal-zielonka <146939528+michal-zielonka@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:09:02 +0200 Subject: [PATCH] feat(Entitlements): Add Entitlements column to Data Source grid Closes MPT-23079 --- ngui/ui/src/api/restapi/actionTypes.ts | 3 + ngui/ui/src/api/restapi/reducer.ts | 10 +- .../CloudAccountsTable/CloudAccountsTable.tsx | 47 +++-- .../CloudAccountsOverview.tsx | 166 ++++++++++++++++++ .../components/CloudAccountsOverview/index.ts | 5 + .../GetCloudAccountsContainer.tsx | 44 +++++ .../GetCloudAccountsContainer/index.ts | 3 + .../DataSourceTagCell.styles.ts | 21 +++ .../DataSourceTagCell/DataSourceTagCell.tsx | 54 ++++++ .../shared/hooks/useDataSourceTagsByOrg.tsx | 62 +++++++ ngui/ui/src/themes/finops/theme.ts | 1 + ngui/ui/src/translations/en-US/app.json | 3 + 12 files changed, 401 insertions(+), 18 deletions(-) create mode 100644 ngui/ui/src/themes/finops/components/CloudAccountsOverview/CloudAccountsOverview.tsx create mode 100644 ngui/ui/src/themes/finops/components/CloudAccountsOverview/index.ts create mode 100644 ngui/ui/src/themes/finops/containers/GetCloudAccountsContainer/GetCloudAccountsContainer.tsx create mode 100644 ngui/ui/src/themes/finops/containers/GetCloudAccountsContainer/index.ts create mode 100644 ngui/ui/src/themes/finops/shared/components/DataSourceTagCell/DataSourceTagCell.styles.ts create mode 100644 ngui/ui/src/themes/finops/shared/components/DataSourceTagCell/DataSourceTagCell.tsx create mode 100644 ngui/ui/src/themes/finops/shared/hooks/useDataSourceTagsByOrg.tsx 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.",