Skip to content
Merged
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
32 changes: 24 additions & 8 deletions controlplane/admin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ Added for the console:
| `POST /api/v1/orgs/:id/users/:username/disable` | admin | persist `disabled=true` (refused at pgwire connect), reload the snapshot cluster-wide so the block is immediate, AND kill the user's live sessions. Returns `{disabled, killed, …}` |
| `POST /api/v1/orgs/:id/users/:username/enable` | admin | persist `disabled=false` + reload cluster-wide so the user can reconnect at once |
| `GET /api/v1/metrics/panels`, `/metrics/query_range` | viewer | Prometheus proxy (allow-listed panels only) |
| `GET /api/v1/usage/monthly` | admin | cumulative per-team usage per UTC month (CPU-seconds, memory GiB-seconds, S3 GiB-seconds), backing the **Usage** page. Self-gates with `RequireAdmin` (not just RoleGate's method check) because per-team cost data across all orgs is as sensitive as the raw billing families. Reads the SAME billing buffer as `GET /billing/usage`, so retention is the buffer's: acked buckets are deleted, >30d buckets GC'd — `watermark_low` in the response marks where billed data was removed. `?months=N` (default 6, max 36) sets the window |
| `GET /api/v1/usage/monthly` | admin | cumulative per-team usage per UTC month (CPU-seconds, memory GiB-seconds, S3 GiB-seconds), backing the **Usage** page. The response also identifies the control plane's effective `aws_region` and its derived `customer_pricing_region`. Self-gates with `RequireAdmin` (not just RoleGate's method check) because per-team cost data across all orgs is as sensitive as the raw billing families. Reads the SAME billing buffer as `GET /billing/usage`, so retention is the buffer's: acked buckets are deleted, >30d buckets GC'd — `watermark_low` in the response marks where billed data was removed. `?months=N` (default 6, max 36) sets the window |
| `GET /api/v1/orgs/:id/usage/daily` | admin | one org's daily per-team usage series (same families), backing the org detail page's **Usage** charts. Same RequireAdmin gate and buffer-retention semantics; the org scope is the `:id` path segment flowing into the queries' WHERE clause. `?days=N` (default 14, max 31 — the buffer's 30d GC bounds useful range) |
| `GET /api/v1/orgs/:id/monitoring/snapshot` | internal secret | Customer-safe org warehouse state, resource limits, workers, sessions, queue depth, and CP coverage. Omits user, pod, image, SQL, client, trace, and control-plane identifiers |
| `GET /api/v1/orgs/:id/monitoring/series` | internal secret | Customer-safe, org-forced Prometheus range query. Requires an allow-listed `metric`; `window` is one of `1h`, `6h`, `24h` (default), `7d`, `30d` |
Expand All @@ -92,13 +92,21 @@ Added for the console:

The Usage page currently presents storage only. It converts retained S3 GiB·h
to GiB-month using the actual number of hours in the selected UTC calendar
month. The storage-economics view defaults to the US customer pricing schedule;
operators can switch it to EU. Customer tiers are progressive and calculated
separately for each organization. The AWS cost is a capacity-only estimate using
public S3 Standard us-east-1 rates across aggregate usage in the view, then
allocated to organizations in proportion to their usage. It excludes requests, transfers,
taxes, credits, negotiated discounts, and other AWS account usage, so it is not
an invoice or an exact CUR charge.
month. Customer pricing is selected automatically from the control plane's
effective `DUCKGRES_AWS_REGION`: `us-*` uses the US schedule and `eu-*` uses
the EU schedule. Missing or unsupported regions make this endpoint return 503
rather than showing a plausible but incorrect price. Customer tiers are
progressive and calculated separately for each organization. Every column is
sortable; unavailable gross margins stay last in both directions. “Price at
80% margin” is a what-if value equal to allocated AWS cost divided by 20%
(`5 × cost`), calculated before currency rounding.

The AWS cost model remains separate from customer pricing: it is a
capacity-only estimate using public S3 Standard us-east-1 rates across
aggregate usage in the view, then allocated to organizations in proportion to
their usage. It excludes requests, transfers, taxes, credits, negotiated
discounts, and other AWS account usage, so it is not an invoice or an exact
CUR charge.

### Cross-CP live-state aggregation (`live_aggregate.go` + `controlplane/live_aggregator.go`)

Expand Down Expand Up @@ -262,6 +270,14 @@ just ui-dev mw-prod-us-admin # → RED prod banner
The SPA uses relative `/api/v1` paths, so the same bundle runs identically
embedded, under Vite, or under the Go devserver.

For local usage-pricing development, start the proxied control plane with an
explicit `--aws-region` or `DUCKGRES_AWS_REGION`; there is intentionally no
pricing-region default. If `/usage` reports that customer pricing is
unsupported, inspect the effective AWS region, correct it to the deployment's
`us-*` or `eu-*` region, and restart/redeploy the control plane. Do not work
around the error by inferring the browser domain or an individual org's bucket
region.

## Tests

**Backend:** `authz_test.go` (SSO role mapping, RoleGate, SQL classifier),
Expand Down
4 changes: 2 additions & 2 deletions controlplane/admin/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ type OrgStackInfo interface {
// RegisterAPI registers all admin REST endpoints on the given router group.
// fetcher (may be nil) aggregates per-CP live state (sessions/workers) across
// replicas so the dashboard shows cluster-wide numbers instead of one CP's slice.
func RegisterAPI(r *gin.RouterGroup, store *configstore.ConfigStore, info OrgStackInfo, fetcher PeerFetcher) {
func RegisterAPI(r *gin.RouterGroup, store *configstore.ConfigStore, info OrgStackInfo, fetcher PeerFetcher, awsRegion string) {
registerAPIWithStore(r, newGormAPIStore(store), info, fetcher)
// Generic read-only models explorer (sidebar + table + detail UI). Reads
// the concrete store directly because it needs the runtime schema name and
Expand All @@ -97,7 +97,7 @@ func RegisterAPI(r *gin.RouterGroup, store *configstore.ConfigStore, info OrgSta
registerOperatorsAPI(r, store)
// Monthly per-team usage for the "Usage" page — a read-only view over the
// billing buffer (see usage_api.go).
registerUsageAPI(r, store)
registerUsageAPI(r, store, awsRegion)
}

func registerAPIWithStore(r *gin.RouterGroup, store apiStore, info OrgStackInfo, fetcher PeerFetcher) {
Expand Down
15 changes: 15 additions & 0 deletions controlplane/admin/ui/src/lib/pricing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ describe("storage pricing", () => {
expect(priced.rows[0].price).toBeCloseTo(19.5);
expect(priced.rows[0].grossProfit).toBeCloseTo(5.7);
expect(priced.rows[0].grossMarginPercent).toBeCloseTo(29.2307);
expect(priced.rows[0].priceAt80PercentMargin).toBeCloseTo(69);
expect(
(priced.rows[0].priceAt80PercentMargin - priced.rows[0].cost) /
priced.rows[0].priceAt80PercentMargin,
).toBeCloseTo(0.8);

expect(priced.rows[1].cost).toBeCloseTo(1.15);
expect(priced.rows[1].price).toBe(0);
Expand All @@ -86,6 +91,16 @@ describe("storage pricing", () => {
expect(priced.summary.price).toBeCloseTo(19.5);
expect(priced.summary.grossProfit).toBeCloseTo(4.55);
expect(priced.summary.grossMarginPercent).toBeCloseTo(23.3333);
expect(priced.summary.priceAt80PercentMargin).toBeCloseTo(74.75);
expect(priced.summary.priceAt80PercentMargin).toBeCloseTo(
priced.rows.reduce((sum, row) => sum + row.priceAt80PercentMargin, 0),
);
});

it("reports a zero target price when allocated storage cost is zero", () => {
const priced = priceStorageByOrg([{ orgId: "org-a", storageGiBHours: 0 }], "2026-08", "US");
expect(priced.rows[0].priceAt80PercentMargin).toBe(0);
expect(priced.summary.priceAt80PercentMargin).toBe(0);
});
});

Expand Down
7 changes: 7 additions & 0 deletions controlplane/admin/ui/src/lib/pricing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface OrgStoragePricing extends OrgUsageTotals {
price: number;
grossProfit: number;
grossMarginPercent: number | null;
priceAt80PercentMargin: number;
}

export interface StoragePricingSummary {
Expand All @@ -25,6 +26,7 @@ export interface StoragePricingSummary {
price: number;
grossProfit: number;
grossMarginPercent: number | null;
priceAt80PercentMargin: number;
}

type StorageTier = {
Expand Down Expand Up @@ -80,6 +82,9 @@ export function customerPriceTooltip(region: PricingRegion): string {
export const GROSS_MARGIN_TOOLTIP =
"Gross margin is gross profit divided by customer price, where gross profit is customer price minus allocated AWS storage cost. The table shows both. Free-tier organizations can have negative gross profit because S3 still costs us; margin is unavailable when customer price is $0.";

export const PRICE_AT_80_PERCENT_MARGIN_TOOLTIP =
"Price that would produce an 80% storage gross margin for this organization using its allocated estimated AWS storage cost: target price = allocated cost ÷ (1 − 0.80) = 5 × allocated cost. This is a what-if value, not the current progressive customer price, and it inherits the AWS cost estimate's exclusions and allocation assumptions. At zero allocated cost it displays $0.00; margin itself is undefined when both cost and price are zero.";

export const BINARY_UNITS_NOTE =
"Storage follows AWS's binary convention: 1 GB = 2^30 bytes (1 GiB), and 1 TB = 2^40 bytes (1024 GB).";

Expand Down Expand Up @@ -151,6 +156,7 @@ export function priceStorageByOrg(
price,
grossProfit,
grossMarginPercent: price > 0 ? (grossProfit / price) * 100 : null,
priceAt80PercentMargin: cost / (1 - 0.8),
};
});

Expand All @@ -166,6 +172,7 @@ export function priceStorageByOrg(
price,
grossProfit,
grossMarginPercent: price > 0 ? (grossProfit / price) * 100 : null,
priceAt80PercentMargin: totalCost / (1 - 0.8),
},
};
}
Expand Down
26 changes: 25 additions & 1 deletion controlplane/admin/ui/src/pages/Usage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ const gibSeconds = (gibMonths: number) => gibMonths * AUGUST_HOURS * 3600;
const RESPONSE: MonthlyUsageResponse = {
from: "2026-06-01T00:00:00Z",
months: 3,
aws_region: "us-east-1",
customer_pricing_region: "US",
watermark_low: "2026-07-20T00:00:00Z",
rows: [
// Two historical team stamps for the same org must become one storage row.
Expand Down Expand Up @@ -84,13 +86,26 @@ describe("Usage page", () => {

expect(within(screen.getByTestId("stat-Total cost")).getByText("$14.95")).toBeInTheDocument();
expect(within(screen.getByTestId("stat-Total price")).getByText("$19.50")).toBeInTheDocument();
expect(within(screen.getByTestId("stat-Total price")).getByText("US progressive tiers · us-east-1")).toBeInTheDocument();
expect(within(screen.getByTestId("stat-Total gross margin")).getByText("23.3%")).toBeInTheDocument();
expect(within(screen.getByTestId("stat-Total gross margin")).getByText("$4.55 gross profit")).toBeInTheDocument();
expect(screen.queryByTestId("stat-S3 GiB·h")).not.toBeInTheDocument();
expect(screen.queryByTestId("stat-CPU-min")).not.toBeInTheDocument();
expect(screen.queryByTestId("stat-Memory GiB·min")).not.toBeInTheDocument();
});

it("automatically applies EU pricing reported by the deployment", () => {
hooks.useMonthlyUsage.mockReturnValue(
ok({ ...RESPONSE, aws_region: "eu-central-1", customer_pricing_region: "EU" }),
);
renderPage();

expect(within(screen.getByTestId("stat-Total price")).getByText("$21.45")).toBeInTheDocument();
expect(within(screen.getByTestId("stat-Total price")).getByText("EU progressive tiers · eu-central-1")).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: /customer price.*eu/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /us pricing|eu pricing/i })).not.toBeInTheDocument();
});

it("renders the retention caveat when billing has acked a watermark", () => {
hooks.useMonthlyUsage.mockReturnValue(ok(RESPONSE));
renderPage();
Expand All @@ -99,7 +114,16 @@ describe("Usage page", () => {
});

it("renders an empty state when there is no usage", () => {
hooks.useMonthlyUsage.mockReturnValue(ok({ from: "2026-06-01T00:00:00Z", months: 3, watermark_low: null, rows: [] }));
hooks.useMonthlyUsage.mockReturnValue(
ok({
from: "2026-06-01T00:00:00Z",
months: 3,
aws_region: "us-east-1",
customer_pricing_region: "US",
watermark_low: null,
rows: [],
}),
);
renderPage();
expect(screen.getByText(/no usage has been recorded/i)).toBeInTheDocument();
});
Expand Down
12 changes: 3 additions & 9 deletions controlplane/admin/ui/src/pages/Usage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ function currentMonth(): string {
export function Usage() {
const { isAdmin } = useIdentity();
const [months, setMonths] = useState(6);
const [pricingRegion, setPricingRegion] = useState<PricingRegion>("US");
const usage = useMonthlyUsage(months);
const orgLabels = useOrgLabels();
const rows = useMemo(() => usage.data?.rows ?? [], [usage.data]);
Expand All @@ -52,6 +51,7 @@ export function Usage() {
// team stamp, so collapse those rows before display rather than showing
// duplicate-looking org lines when that stamp changed within the month.
const orgRows = useMemo(() => orgTotals(monthRows), [monthRows]);
const pricingRegion: PricingRegion = usage.data?.customer_pricing_region ?? "US";
const pricing = useMemo(
() => priceStorageByOrg(orgRows, month, pricingRegion),
[orgRows, month, pricingRegion],
Expand Down Expand Up @@ -132,7 +132,7 @@ export function Usage() {
label="Total price"
value={fmtMoney(pricing.summary.price)}
icon={<InfoTooltip label="Explain customer price" text={customerPriceTooltip(pricingRegion)} />}
hint={`${pricingRegion} progressive customer tiers`}
hint={`${pricingRegion} progressive tiers · ${usage.data?.aws_region}`}
/>
<StatCard
label="Total gross margin"
Expand All @@ -153,13 +153,7 @@ export function Usage() {
description={`No usage has been recorded for ${month} in the retained billing buffer.`}
/>
) : (
<UsagePricing
rows={monthRows}
labels={orgLabels}
month={month}
region={pricingRegion}
onRegionChange={setPricingRegion}
/>
<UsagePricing rows={monthRows} labels={orgLabels} month={month} region={pricingRegion} />
)}
</>
)}
Expand Down
56 changes: 47 additions & 9 deletions controlplane/admin/ui/src/pages/UsagePricing.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { TooltipProvider } from "@/components/ui/tooltip";
Expand All @@ -12,23 +12,32 @@ const ROWS: MonthlyUsageRow[] = [
{ month: "2026-08", org_id: "acme", team_id: 5, schema_name: "team_5", cpu_seconds: 1, memory_seconds: 1, gib_seconds: gibSeconds(200) },
{ month: "2026-08", org_id: "acme", team_id: 6, schema_name: "team_6", cpu_seconds: 1, memory_seconds: 1, gib_seconds: gibSeconds(400) },
{ month: "2026-08", org_id: "globex", team_id: 9, schema_name: "team_9", cpu_seconds: 1, memory_seconds: 1, gib_seconds: gibSeconds(50) },
{ month: "2026-08", org_id: "initech", team_id: 10, schema_name: "team_10", cpu_seconds: 1, memory_seconds: 1, gib_seconds: gibSeconds(200) },
];

const LABELS = new Map([
["acme", "Acme Inc"],
["globex", "Globex Corp"],
["initech", "Aardvark Systems"],
]);

function renderPricing(region: PricingRegion = "US", onRegionChange = () => {}) {
function renderPricing(region: PricingRegion = "US") {
return render(
<MemoryRouter>
<TooltipProvider delayDuration={0}>
<UsagePricing rows={ROWS} labels={LABELS} month="2026-08" region={region} onRegionChange={onRegionChange} />
<UsagePricing rows={ROWS} labels={LABELS} month="2026-08" region={region} />
</TooltipProvider>
</MemoryRouter>,
);
}

function visibleOrgOrder(): string[] {
return screen
.getAllByRole("link")
.map((link) => link.querySelector("span > span")?.textContent?.trim() ?? "")
.filter(Boolean);
}

describe("UsagePricing", () => {
it("separates AWS cost, customer price, and gross margin per org", () => {
renderPricing();
Expand All @@ -41,18 +50,20 @@ describe("UsagePricing", () => {
expect(within(globex).getByText("$1.15")).toBeInTheDocument();
expect(within(globex).getByText("$0.00")).toBeInTheDocument();
expect(within(globex).getByText("N/A (-$1.15 profit)")).toBeInTheDocument();

const acmeCells = within(acme).getAllByRole("cell");
expect(acmeCells[4]).toHaveTextContent("$69.00");
});

it("shows the binary-unit note on the pricing page", () => {
renderPricing();
expect(screen.getByText(/1 GB = 2\^30 bytes.*1 TB = 2\^40 bytes.*1024 GB/i)).toBeInTheDocument();
});

it("offers the supplied US and EU customer pricing schedules", () => {
const onRegionChange = vi.fn();
renderPricing("US", onRegionChange);
fireEvent.click(screen.getByRole("button", { name: "EU pricing" }));
expect(onRegionChange).toHaveBeenCalledWith("EU");
it("shows the deployment-selected customer schedule without a region switch", () => {
renderPricing("EU");
expect(screen.getByRole("columnheader", { name: /customer price.*eu/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /us pricing|eu pricing/i })).not.toBeInTheDocument();
});

it("adds accessible explanations for usage, cost, price, and margin", async () => {
Expand All @@ -61,6 +72,7 @@ describe("UsagePricing", () => {
["Explain S3 GiB·h", /storage over time, not current bucket size or a transfer rate/i],
["Explain AWS storage cost", /S3 Standard storage cost at public us-east-1 rates.*storage capacity only/i],
["Explain customer price", /progressive US monthly tiers.*first 100 GiB-month/i],
["Explain price at 80% margin", /80% storage gross margin.*5 × allocated cost/i],
["Explain gross margin", /gross margin is gross profit divided by customer price/i],
] as const;

Expand All @@ -71,10 +83,36 @@ describe("UsagePricing", () => {
}
});

it("sorts initially by displayed org label and toggles organization order", () => {
renderPricing();
expect(visibleOrgOrder()).toEqual(["Aardvark Systems", "Acme Inc", "Globex Corp"]);
expect(screen.getByRole("columnheader", { name: /org/i })).toHaveAttribute("aria-sort", "ascending");

fireEvent.click(screen.getByRole("button", { name: "Sort by Org" }));
expect(visibleOrgOrder()).toEqual(["Globex Corp", "Acme Inc", "Aardvark Systems"]);
expect(screen.getByRole("columnheader", { name: /org/i })).toHaveAttribute("aria-sort", "descending");
});

it.each([
["S3 GiB·h", ["Acme Inc", "Aardvark Systems", "Globex Corp"], ["Globex Corp", "Aardvark Systems", "Acme Inc"]],
["Allocated AWS cost", ["Acme Inc", "Aardvark Systems", "Globex Corp"], ["Globex Corp", "Aardvark Systems", "Acme Inc"]],
["Customer price (US)", ["Acme Inc", "Aardvark Systems", "Globex Corp"], ["Globex Corp", "Aardvark Systems", "Acme Inc"]],
["Price at 80% margin", ["Acme Inc", "Aardvark Systems", "Globex Corp"], ["Globex Corp", "Aardvark Systems", "Acme Inc"]],
["Gross margin", ["Acme Inc", "Aardvark Systems", "Globex Corp"], ["Aardvark Systems", "Acme Inc", "Globex Corp"]],
])("sorts %s numerically in both directions with unavailable margins last", (heading, descending, ascending) => {
renderPricing();
const sort = screen.getByRole("button", { name: `Sort by ${heading}` });
fireEvent.click(sort);
expect(visibleOrgOrder()).toEqual(descending);
fireEvent.click(sort);
expect(visibleOrgOrder()).toEqual(ascending);
expect(screen.getByText("All orgs").closest("tr")).toBe(screen.getAllByRole("row").at(-1));
});

it("renders a friendly empty state without usage rows", () => {
render(
<MemoryRouter>
<UsagePricing rows={[]} labels={LABELS} month="2026-08" region="US" onRegionChange={() => {}} />
<UsagePricing rows={[]} labels={LABELS} month="2026-08" region="US" />
</MemoryRouter>,
);
expect(screen.getByText(/no usage rows/i)).toBeInTheDocument();
Expand Down
Loading
Loading