Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions console/src/api/cloudGlobalApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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 = {},
) {
Expand Down
9 changes: 9 additions & 0 deletions console/src/api/mocks/cloudGlobalApiHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { addDays, differenceInDays, formatISO, parseISO } from "date-fns";
import { http, HttpResponse } from "msw";

import {
CostBreakdown,
CreditBlock,
DailyCosts,
Invoice,
Expand Down Expand Up @@ -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 } = {},
) =>
Expand Down
96 changes: 96 additions & 0 deletions console/src/api/schemas/global-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"];
Expand Down Expand Up @@ -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: {
Expand Down
213 changes: 213 additions & 0 deletions console/src/platform/billing/AccountClusterBreakdown.tsx
Original file line number Diff line number Diff line change
@@ -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<MaterializeTheme>();
const { isOpen, onToggle } = useDisclosure({ defaultIsOpen: true });

const groupHeaderStyles = {
...baseCellStyles,
height: 16,
textStyle: "heading-xs",
borderBottom: 0,
borderTop: "1px solid",
borderColor: colors.border.secondary,
};

return (
<>
<Box
display="contents"
role="row"
onClick={onToggle}
cursor="pointer"
data-testid="account-row"
>
<Box {...groupHeaderStyles} role="cell">
<ChevronRightIcon
width="4"
height="4"
transform={`rotate(${isOpen ? 90 : 0}deg)`}
transition="all 0.1s"
marginRight="2"
/>
{account.external_customer_id}
</Box>
<Box {...groupHeaderStyles} role="cell" justifyContent="end">
{formatCurrency(accountTotal(account))}
</Box>
</Box>
<SafariSafeCollapse
isCollapsed={!isOpen}
rowCount={account.clusters.length}
>
{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 (
<React.Fragment
key={`${cluster.environment_id}/${cluster.cluster_grouping_key}/${ix}`}
>
<Box
{...cellStyles}
paddingLeft={resourceTypePaddingLeft}
whiteSpace="nowrap"
role="cell"
>
{clusterLabel(cluster)}
</Box>
<Box {...cellStyles} role="cell" justifyContent="end">
{formatCurrency(clusterTotal(cluster.amounts))}
</Box>
</React.Fragment>
);
})}
</SafariSafeCollapse>
</>
);
};

/**
* 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<MaterializeTheme>();

const headerStyles = {
...baseCellStyles,
height: 10,
textStyle: "text-ui-med",
color: colors.foreground.secondary,
borderColor: colors.border.secondary,
};

return (
<Box data-testid="account-cluster-breakdown">
<Text textStyle="heading-sm" mb={4}>
Spend by account &amp; cluster
</Text>
{isLoading ? (
<Spinner data-testid="account-breakdown-loading" />
) : isError ? (
<ErrorBox
message={error?.message || "There was an error fetching your usage."}
/>
) : !breakdown || breakdown.accounts.length === 0 ? (
<Text
textStyle="text-ui-reg"
color={colors.foreground.secondary}
data-testid="account-breakdown-empty"
>
No usage to break down for the selected period.
</Text>
) : (
<Grid
gridTemplateColumns="minmax(250px, 1fr) minmax(120px, auto)"
role="table"
borderBottom="1px solid"
borderBottomColor={colors.border.secondary}
>
<Box {...headerStyles} role="columnheader">
Account / cluster
</Box>
<Box {...headerStyles} role="columnheader" justifyContent="end">
Total cost
</Box>
{breakdown.accounts.map((account) => (
<AccountGroup
key={account.external_customer_id}
account={account}
/>
))}
</Grid>
)}
</Box>
);
};

export default AccountClusterBreakdown;
Loading
Loading