diff --git a/console/src/api/cloudGlobalApi.ts b/console/src/api/cloudGlobalApi.ts index 7375c3d7e518b..2f64ee26f1698 100644 --- a/console/src/api/cloudGlobalApi.ts +++ b/console/src/api/cloudGlobalApi.ts @@ -27,6 +27,11 @@ export type Region = components["schemas"]["Region"]; export type Regions = components["schemas"]["Regions"]; export type CreditBlock = components["schemas"]["CreditBlock"]; export type DailyCosts = components["schemas"]["DailyCostResponse"]; +export type CostBreakdown = components["schemas"]["CostBreakdownResponse"]; +export type CostBreakdownAccount = + components["schemas"]["CostBreakdownAccount"]; +export type CostBreakdownCluster = + components["schemas"]["CostBreakdownCluster"]; export type AllCosts = components["schemas"]["AllCosts"]; export type DailyCostKey = keyof components["schemas"]["AllCosts"]; export type Prices = @@ -167,6 +172,26 @@ export async function getDailyCosts( return handleOpenApiResponseWithBody(data, response); } +export async function getCostsBreakdown( + startDate: Date, + endDate: Date, + requestOptions: OpenApiRequestOptions = {}, +) { + const { headers, ...options } = requestOptions; + const { data, response } = await getClient().GET("/api/costs/breakdown", { + params: { + query: { + startDate: formatRFC3339(startDate), + endDate: formatRFC3339(endDate), + }, + }, + signal: requestOptions?.signal, + headers, + ...options, + }); + return handleOpenApiResponseWithBody(data, response); +} + export async function createStripeSetupIntent( requestOptions: OpenApiRequestOptions = {}, ) { diff --git a/console/src/api/mocks/cloudGlobalApiHandlers.ts b/console/src/api/mocks/cloudGlobalApiHandlers.ts index 19a26df3e5635..3c25b9905289b 100644 --- a/console/src/api/mocks/cloudGlobalApiHandlers.ts +++ b/console/src/api/mocks/cloudGlobalApiHandlers.ts @@ -11,6 +11,7 @@ import { addDays, differenceInDays, formatISO, parseISO } from "date-fns"; import { http, HttpResponse } from "msw"; import { + CostBreakdown, CreditBlock, DailyCosts, Invoice, @@ -127,6 +128,14 @@ export const buildDailyCostResponse = ( return HttpResponse.json(payload, { status: options.status ?? 200 }); }); +export const buildCostBreakdownResponse = ( + options: { payload?: CostBreakdown; status?: number } = {}, +) => + http.get("*/api/costs/breakdown", () => { + const payload: CostBreakdown = options.payload ?? { accounts: [] }; + return HttpResponse.json(payload, { status: options.status ?? 200 }); + }); + export const buildInvoicesResponse = ( options: { invoices?: Invoice[]; status?: number } = {}, ) => diff --git a/console/src/api/schemas/global-api.ts b/console/src/api/schemas/global-api.ts index 4a1500c2cde47..255b88bf17722 100644 --- a/console/src/api/schemas/global-api.ts +++ b/console/src/api/schemas/global-api.ts @@ -46,6 +46,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/costs/breakdown": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get per-child per-cluster cost breakdown for an organization */ + get: operations["get_cost_breakdown"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/costs/daily": { parameters: { query?: never; @@ -276,6 +293,48 @@ export interface components { /** @description The total cost for the resource type in the region, excluding any minimums and discounts. */ subtotal: string; }; + /** @description Cost breakdown for one Orb account (parent or child) within a billing period. */ + CostBreakdownAccount: { + external_customer_id: string; + clusters: components["schemas"]["CostBreakdownCluster"][]; + }; + /** @description Per-cluster cost breakdown for a single Orb account within a billing period. */ + CostBreakdownCluster: { + /** @description The environment that owns this cluster (event property `environment_id`). */ + environment_id: string; + /** + * @description Human-readable cluster key in `cluster_name.replica_name` form + * (event property `cluster_grouping_key`). Empty for storage/egress rows + * that have no per-cluster breakdown. + */ + cluster_grouping_key: string; + /** + * @description Resource category for rows that aren't a compute cluster — "Storage" or + * "Egress", derived from the Orb price's item name — so storage and egress + * (which share an empty `cluster_grouping_key`) render as separate rows. + * Empty for compute rows, which are identified by `cluster_grouping_key`. + */ + category: string; + /** + * @description Cloud region in `provider/region` form, e.g. "aws/us-east-1" (event + * property `region`). Lets the frontend qualify each row ("aws/us-east-1 / + * Storage"), matching the daily "Spend between …" table. + */ + region: string; + /** @description Map from price_id to the computed dollar amount (e.g. "1.23"). */ + amounts: { + [key: string]: string; + }; + }; + /** + * @description Response for `GET /api/costs/breakdown`. + * + * Contains one entry per Orb account (the parent plus any sub-accounts). + * Each account lists per-cluster costs computed via Orb's evaluate-prices API. + */ + CostBreakdownResponse: { + accounts: components["schemas"]["CostBreakdownAccount"][]; + }; /** @description The daily costs for a given customer. */ CostBucket: { costs: components["schemas"]["AllCosts"]; @@ -574,6 +633,43 @@ export interface operations { }; }; }; + get_cost_breakdown: { + parameters: { + query: { + startDate: string; + endDate: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Organization Cost Breakdown */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CostBreakdownResponse"]; + }; + }; + /** @description Missing or invalid startDate/endDate query params */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Insufficient permissions */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; get_current_organization_daily_costs: { parameters: { query: { diff --git a/console/src/platform/billing/AccountClusterBreakdown.tsx b/console/src/platform/billing/AccountClusterBreakdown.tsx new file mode 100644 index 0000000000000..1c9291d28b518 --- /dev/null +++ b/console/src/platform/billing/AccountClusterBreakdown.tsx @@ -0,0 +1,213 @@ +// Copyright Materialize, Inc. and contributors. All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +import { + Box, + Grid, + Spinner, + Text, + useDisclosure, + useTheme, +} from "@chakra-ui/react"; +import React from "react"; + +import { + CostBreakdown, + CostBreakdownAccount, + CostBreakdownCluster, +} from "~/api/cloudGlobalApi"; +import ErrorBox from "~/components/ErrorBox"; +import ChevronRightIcon from "~/svg/ChevronRightIcon"; +import { MaterializeTheme } from "~/theme"; +import { formatCurrency } from "~/utils/format"; + +import { baseCellStyles, resourceTypePaddingLeft } from "./constants"; +import { SafariSafeCollapse } from "./SpendBreakdown"; + +type AccountClusterBreakdownProps = { + breakdown: CostBreakdown | null; + isLoading: boolean; + isError: boolean; + error: Error | null; +}; + +/** Sum a cluster's per-price amounts (dollar strings) into a single total. */ +function clusterTotal(amounts: { [priceId: string]: string }): number { + return Object.values(amounts).reduce( + (sum, amount) => sum + parseFloat(amount), + 0, + ); +} + +function accountTotal(account: CostBreakdownAccount): number { + return account.clusters.reduce( + (sum, cluster) => sum + clusterTotal(cluster.amounts), + 0, + ); +} + +/** + * Region-qualified label for a cluster row, e.g. "aws/us-east-1 / quickstart.r1", + * "aws/us-east-1 / Storage", or "aws/us-east-1 / Egress" — mirroring the daily + * "Spend between …" table's `{region} / {resourceType}` format. Compute clusters + * carry a `cluster.replica` grouping key; storage/egress rows have an empty key + * and instead carry a `category` ("Storage" / "Egress") so the two render as + * distinct rows (see interface.rs). The final "Other" is a defensive fallback + * for the unexpected case where neither is set. + */ +function clusterLabel(cluster: CostBreakdownCluster): string { + const label = cluster.cluster_grouping_key || cluster.category || "Other"; + return `${cluster.region} / ${label}`; +} + +/** + * A single account rendered as a collapsible group, mirroring SpendBreakdown's + * `ResourceGroup`: the account is the always-visible parent row (caret + total) + * and its clusters are indented child rows revealed by the disclosure. Groups + * default open so the breakdown is visible without interaction. + */ +const AccountGroup = ({ account }: { account: CostBreakdownAccount }) => { + const { colors } = useTheme(); + const { isOpen, onToggle } = useDisclosure({ defaultIsOpen: true }); + + const groupHeaderStyles = { + ...baseCellStyles, + height: 16, + textStyle: "heading-xs", + borderBottom: 0, + borderTop: "1px solid", + borderColor: colors.border.secondary, + }; + + return ( + <> + + + + {account.external_customer_id} + + + {formatCurrency(accountTotal(account))} + + + + {account.clusters.map((cluster, ix) => { + const isLastElement = ix === account.clusters.length - 1; + const cellStyles = { + ...baseCellStyles, + borderColor: "transparent", + height: isLastElement ? 10 : baseCellStyles.height, + paddingBottom: isLastElement ? "8px" : "unset", + }; + return ( + + + {clusterLabel(cluster)} + + + {formatCurrency(clusterTotal(cluster.amounts))} + + + ); + })} + + + ); +}; + +/** + * Per-account, per-cluster cost breakdown (Phase 1 / SAS-128). Augments the + * existing daily chart: a parent org sees itself plus each child account, a + * child sees only its own account, and a standalone org sees a single account. + * Shows one total per cluster (the sum of its price amounts) over the selected + * period — a compute/storage split is deferred to a later phase. + */ +const AccountClusterBreakdown = ({ + breakdown, + isLoading, + isError, + error, +}: AccountClusterBreakdownProps) => { + const { colors } = useTheme(); + + const headerStyles = { + ...baseCellStyles, + height: 10, + textStyle: "text-ui-med", + color: colors.foreground.secondary, + borderColor: colors.border.secondary, + }; + + return ( + + + Spend by account & cluster + + {isLoading ? ( + + ) : isError ? ( + + ) : !breakdown || breakdown.accounts.length === 0 ? ( + + No usage to break down for the selected period. + + ) : ( + + + Account / cluster + + + Total cost + + {breakdown.accounts.map((account) => ( + + ))} + + )} + + ); +}; + +export default AccountClusterBreakdown; diff --git a/console/src/platform/billing/SpendBreakdown.tsx b/console/src/platform/billing/SpendBreakdown.tsx index 2824c86698543..8e7f9dc887a42 100644 --- a/console/src/platform/billing/SpendBreakdown.tsx +++ b/console/src/platform/billing/SpendBreakdown.tsx @@ -34,7 +34,11 @@ import { formatBytes, isSafari } from "~/util"; import { DATE_FORMAT_SHORT, formatDateInUtc } from "~/utils/dateFormat"; import { formatCurrency } from "~/utils/format"; -import { costUnits } from "./constants"; +import { + baseCellStyles, + costUnits, + resourceTypePaddingLeft, +} from "./constants"; import { ResourceBreakdown, ResourceMeasurement } from "./types"; import { summarizeResourceCosts } from "./utils"; @@ -139,17 +143,6 @@ const CreditsMetric = ({ credits }: { credits: number }) => { ); }; -const resourceTypePaddingLeft = 4 + 4 + 2; // table cell + width of caret + caret/label gap - -const baseCellStyles = { - px: 4, - my: "auto", - display: "flex", - alignItems: "center", - height: 8, - borderBottom: "1px solid", -}; - function getMinWithDefault(left: number, right: number): number { /// We initialize min values with -1, so it always loses. if (left === -1) { @@ -401,7 +394,7 @@ const ResourceGroup = ({ * StackOverflow post where someone encounters a similar issue: * https://stackoverflow.com/q/77927259/214197 */ -const SafariSafeCollapse = ({ +export const SafariSafeCollapse = ({ children, isCollapsed, rowCount, diff --git a/console/src/platform/billing/UsagePage.test.tsx b/console/src/platform/billing/UsagePage.test.tsx index 64c6cd6f8f0e4..a2a5547043c7e 100644 --- a/console/src/platform/billing/UsagePage.test.tsx +++ b/console/src/platform/billing/UsagePage.test.tsx @@ -7,13 +7,14 @@ // the Business Source License, use of this software will be governed // by the Apache License, Version 2.0. -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen, waitFor, within } from "@testing-library/react"; import React, { ReactElement } from "react"; import { DailyCosts, Organization } from "~/api/cloudGlobalApi"; import { buildCloudOrganizationsResponse, buildCloudRegionsReponse, + buildCostBreakdownResponse, buildCreditsResponse, buildDailyCostResponse, buildInvoicesResponse, @@ -63,6 +64,7 @@ describe("UsagePage", () => { buildCloudRegionsReponse(), buildInvoicesResponse(), buildCreditsResponse(), + buildCostBreakdownResponse(), buildCloudOrganizationsResponse({ payload: buildOrganization(), }), @@ -102,6 +104,174 @@ describe("UsagePage", () => { ); }); + it("renders a per-account, per-cluster breakdown for a parent org", async () => { + server.use(buildDailyCostResponse()); + server.use( + buildCostBreakdownResponse({ + payload: { + accounts: [ + { + external_customer_id: "parent-org", + clusters: [ + { + environment_id: "environment-parent-0", + cluster_grouping_key: "quickstart.r1", + category: "", + region: "aws/us-east-1", + amounts: { "price-compute": "10.00" }, + }, + { + environment_id: "environment-parent-0", + cluster_grouping_key: "compute.r1", + category: "", + region: "aws/us-east-1", + amounts: { "price-compute": "4.00" }, + }, + ], + }, + { + external_customer_id: "child-org", + clusters: [ + { + environment_id: "environment-child-0", + cluster_grouping_key: "prod.r1", + category: "", + region: "aws/us-east-1", + amounts: { "price-compute": "5.00" }, + }, + { + environment_id: "environment-child-0", + cluster_grouping_key: "prod.r2", + category: "", + region: "aws/us-east-1", + amounts: { "price-compute": "2.00" }, + }, + ], + }, + ], + }, + }), + ); + renderComponent(); + + // Both accounts and all clusters appear, each cluster row region-qualified + // ("aws/us-east-1 / "), matching the daily "Spend between …" table. + expect( + await screen.findByText("parent-org", {}, { timeout: 5_000 }), + ).toBeVisible(); + expect(await screen.findByText("child-org")).toBeVisible(); + for (const cluster of [ + "quickstart.r1", + "compute.r1", + "prod.r1", + "prod.r2", + ]) { + expect( + await screen.findByText(`aws/us-east-1 / ${cluster}`), + ).toBeVisible(); + } + // ...with per-account totals (14 = 10 + 4, 7 = 5 + 2) summed from the + // per-cluster amounts. + expect(await screen.findByText(formatCurrency(14))).toBeVisible(); + expect(await screen.findByText(formatCurrency(7))).toBeVisible(); + }); + + it("renders a single account for a standalone org", async () => { + server.use(buildDailyCostResponse()); + server.use( + buildCostBreakdownResponse({ + payload: { + accounts: [ + { + external_customer_id: "standalone-org", + clusters: [ + { + environment_id: "environment-standalone-0", + cluster_grouping_key: "default.r1", + category: "", + region: "aws/us-east-1", + amounts: { "price-compute": "3.00" }, + }, + { + // Storage and egress both have an empty cluster_grouping_key; + // their `category` keeps them on separate rows, rendered as + // " / Storage" and " / Egress". + environment_id: "environment-standalone-0", + cluster_grouping_key: "", + category: "Storage", + region: "aws/us-east-1", + amounts: { "price-storage": "0.50" }, + }, + { + environment_id: "environment-standalone-0", + cluster_grouping_key: "", + category: "Egress", + region: "aws/us-east-1", + amounts: { "price-egress": "0.25" }, + }, + ], + }, + ], + }, + }), + ); + renderComponent(); + + const accountRows = await screen.findAllByTestId( + "account-row", + {}, + { timeout: 5_000 }, + ); + expect(accountRows).toHaveLength(1); + expect(await screen.findByText("standalone-org")).toBeVisible(); + // Scope cluster-label lookups to this table: SpendBreakdown ("Spend between + // …") renders the same " / Storage" text from the daily costs. + const breakdown = within( + await screen.findByTestId("account-cluster-breakdown"), + ); + expect( + await breakdown.findByText("aws/us-east-1 / default.r1"), + ).toBeVisible(); + // Storage and egress (both empty cluster_grouping_key) render as separate + // rows, distinguished by `category`. + expect(await breakdown.findByText("aws/us-east-1 / Storage")).toBeVisible(); + expect(await breakdown.findByText("aws/us-east-1 / Egress")).toBeVisible(); + }); + + it("falls back to 'Other' when a row has neither cluster key nor category", async () => { + // Defensive: a non-compute row with an empty cluster_grouping_key and no + // category can only occur against a backend that predates the `category` + // field. It should render " / Other" rather than mislabel as a + // cluster or crash. + server.use(buildDailyCostResponse()); + server.use( + buildCostBreakdownResponse({ + payload: { + accounts: [ + { + external_customer_id: "standalone-org", + clusters: [ + { + environment_id: "environment-standalone-0", + cluster_grouping_key: "", + category: "", + region: "aws/us-east-1", + amounts: { "price-storage": "0.50" }, + }, + ], + }, + ], + }, + }), + ); + renderComponent(); + + const breakdown = within( + await screen.findByTestId("account-cluster-breakdown"), + ); + expect(await breakdown.findByText("aws/us-east-1 / Other")).toBeVisible(); + }); + it("changing the region filters the totals", async () => { const [startDate, endDate] = getTimeRange(7); const payload = generateDailyCostResponsePayload(startDate, endDate); diff --git a/console/src/platform/billing/UsagePage.tsx b/console/src/platform/billing/UsagePage.tsx index 3fee621d538b7..e1483b36e7051 100644 --- a/console/src/platform/billing/UsagePage.tsx +++ b/console/src/platform/billing/UsagePage.tsx @@ -29,13 +29,14 @@ import { MaterializeTheme } from "~/theme"; import { nowUTC } from "~/util"; import { formatCurrency } from "~/utils/format"; +import AccountClusterBreakdown from "./AccountClusterBreakdown"; import DailyUsageChart, { chartHeightPx, legendHeightPx, } from "./DailyUsageChart"; import InvoiceTable from "./InvoiceTable"; import { UpgradedPlanDetails } from "./PlanDetails"; -import { useDailyCosts, useRecentInvoices } from "./queries"; +import { useCostsBreakdown, useDailyCosts, useRecentInvoices } from "./queries"; import RegionSelect from "./RegionSelect"; import SpendBreakdown from "./SpendBreakdown"; import TimeRangeSelect from "./TimeRangeSelect"; @@ -211,6 +212,12 @@ const UsagePage = () => { isError: isDailyCostsError, error: dailyCostsError, } = useDailyCosts(timeRangeFilter, lastQueryTime); + const { + data: costBreakdown, + isLoading: isCostBreakdownLoading, + isError: isCostBreakdownError, + error: costBreakdownError, + } = useCostsBreakdown(timeRangeFilter, lastQueryTime); const chartTooltipRef = useRef(null); @@ -226,6 +233,7 @@ const UsagePage = () => { "selects details" "chart details" "spend details" + "breakdown details" "invoices ."`} gridTemplateColumns="minmax(500px, 70%) minmax(300px, 3fr)" gridColumnGap={12} @@ -268,6 +276,14 @@ const UsagePage = () => { totalDays={timeRangeFilter} /> + + + Invoice history diff --git a/console/src/platform/billing/constants.ts b/console/src/platform/billing/constants.ts index 63b905ddbd9a7..1e9e2c670bca3 100644 --- a/console/src/platform/billing/constants.ts +++ b/console/src/platform/billing/constants.ts @@ -22,6 +22,23 @@ export const costUnits = { storage: "GB", } as { [key in DailyCostKey]: string }; +// Left padding for an indented child row in a spend table: the cell's own +// padding, plus the caret width and the gap between caret and label, so child +// labels line up just past the parent row's caret. +export const resourceTypePaddingLeft = 4 + 4 + 2; + +// Shared cell layout for the spend tables (SpendBreakdown / AccountCluster +// breakdown). Callers set `borderColor` for the contexts where the divider +// should show. +export const baseCellStyles = { + px: 4, + my: "auto", + display: "flex", + alignItems: "center", + height: 8, + borderBottom: "1px solid", +}; + export const replicaSorts = new Map([ // These are mapped to their corresponding centicredit value. // See: https://github.com/MaterializeInc/cloud/blob/main/doc/design/20231004_cluster_sizings.md diff --git a/console/src/platform/billing/queries.ts b/console/src/platform/billing/queries.ts index 2cba92856a8ee..b0730edc78bae 100644 --- a/console/src/platform/billing/queries.ts +++ b/console/src/platform/billing/queries.ts @@ -19,6 +19,7 @@ import { import { createStripeSetupIntent, detachPaymentMethod, + getCostsBreakdown, getCredits, getDailyCosts, Organization, @@ -49,6 +50,18 @@ export const dailyCostQueryKeys = { ] as const, }; +export const costBreakdownQueryKeys = { + all: () => buildGlobalQueryKey("costBreakdown"), + list: (timeSpan: number, queryTime: Date) => + [ + ...costBreakdownQueryKeys.all(), + buildQueryKeyPart("list", { + timeSpan, + queryTime, + }), + ] as const, +}; + async function getCreditBalance(requestOptions?: RequestInit): Promise { const { data: responseBody } = await getCredits(requestOptions); return responseBody.data.reduce( @@ -76,12 +89,7 @@ export function useRecentInvoices() { }); } -export function getTimeRange(span: number): [Date, Date] { - // Some fields on the usage page, such as the rolling average in the plan - // details component, requires a certain number of days' worth of data. That - // span of time may be greater than what is being visually filtered, so set - // the minimum-queried range to what the rolling average needs. - span = Math.max(span, ROLLING_AVG_TIME_RANGE_LOOKBACK_DAYS); +function getDayAlignedRange(span: number): [Date, Date] { const endDate = startOfDay(addDays(nowUTC(), 1)); endDate.setUTCHours(0, 0, 0, 0); const startDate = new Date( @@ -93,6 +101,16 @@ export function getTimeRange(span: number): [Date, Date] { return [startDate, endDate]; } +export function getTimeRange(span: number): [Date, Date] { + // Some fields on the usage page, such as the rolling average in the plan + // details component, requires a certain number of days' worth of data. That + // span of time may be greater than what is being visually filtered, so set + // the minimum-queried range to what the rolling average needs. + return getDayAlignedRange( + Math.max(span, ROLLING_AVG_TIME_RANGE_LOOKBACK_DAYS), + ); +} + export function useDailyCosts(timeSpan: number, queryTime: Date) { return useQuery({ queryKey: dailyCostQueryKeys.list(timeSpan, queryTime), @@ -105,6 +123,21 @@ export function useDailyCosts(timeSpan: number, queryTime: Date) { }); } +export function useCostsBreakdown(timeSpan: number, queryTime: Date) { + return useQuery({ + queryKey: costBreakdownQueryKeys.list(timeSpan, queryTime), + queryFn: async ({ queryKey, signal }) => { + const [_, { timeSpan: queryTimeSpan }] = queryKey; + // The breakdown is a single period total (not a sliced daily series), so + // query exactly the selected window rather than the rolling-average- + // extended range getTimeRange() returns. + const [startDate, endDate] = getDayAlignedRange(queryTimeSpan); + const response = await getCostsBreakdown(startDate, endDate, { signal }); + return response.data; + }, + }); +} + export type CreditBalanceResponse = ReturnType; export function useInitializeSetupIntent() {