diff --git a/controlplane/admin/README.md b/controlplane/admin/README.md index 4f7d73a5..6b21dfb0 100644 --- a/controlplane/admin/README.md +++ b/controlplane/admin/README.md @@ -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` | @@ -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`) @@ -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), diff --git a/controlplane/admin/api.go b/controlplane/admin/api.go index 3cfea1f0..ceefcd54 100644 --- a/controlplane/admin/api.go +++ b/controlplane/admin/api.go @@ -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 @@ -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) { diff --git a/controlplane/admin/ui/src/lib/pricing.test.ts b/controlplane/admin/ui/src/lib/pricing.test.ts index 34845812..219bdd8d 100644 --- a/controlplane/admin/ui/src/lib/pricing.test.ts +++ b/controlplane/admin/ui/src/lib/pricing.test.ts @@ -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); @@ -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); }); }); diff --git a/controlplane/admin/ui/src/lib/pricing.ts b/controlplane/admin/ui/src/lib/pricing.ts index af4817f1..17346842 100644 --- a/controlplane/admin/ui/src/lib/pricing.ts +++ b/controlplane/admin/ui/src/lib/pricing.ts @@ -16,6 +16,7 @@ export interface OrgStoragePricing extends OrgUsageTotals { price: number; grossProfit: number; grossMarginPercent: number | null; + priceAt80PercentMargin: number; } export interface StoragePricingSummary { @@ -25,6 +26,7 @@ export interface StoragePricingSummary { price: number; grossProfit: number; grossMarginPercent: number | null; + priceAt80PercentMargin: number; } type StorageTier = { @@ -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)."; @@ -151,6 +156,7 @@ export function priceStorageByOrg( price, grossProfit, grossMarginPercent: price > 0 ? (grossProfit / price) * 100 : null, + priceAt80PercentMargin: cost / (1 - 0.8), }; }); @@ -166,6 +172,7 @@ export function priceStorageByOrg( price, grossProfit, grossMarginPercent: price > 0 ? (grossProfit / price) * 100 : null, + priceAt80PercentMargin: totalCost / (1 - 0.8), }, }; } diff --git a/controlplane/admin/ui/src/pages/Usage.test.tsx b/controlplane/admin/ui/src/pages/Usage.test.tsx index 3a141bf5..85eac0b5 100644 --- a/controlplane/admin/ui/src/pages/Usage.test.tsx +++ b/controlplane/admin/ui/src/pages/Usage.test.tsx @@ -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. @@ -84,6 +86,7 @@ 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(); @@ -91,6 +94,18 @@ describe("Usage page", () => { 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(); @@ -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(); }); diff --git a/controlplane/admin/ui/src/pages/Usage.tsx b/controlplane/admin/ui/src/pages/Usage.tsx index 817c9104..f9c98dc7 100644 --- a/controlplane/admin/ui/src/pages/Usage.tsx +++ b/controlplane/admin/ui/src/pages/Usage.tsx @@ -28,7 +28,6 @@ function currentMonth(): string { export function Usage() { const { isAdmin } = useIdentity(); const [months, setMonths] = useState(6); - const [pricingRegion, setPricingRegion] = useState("US"); const usage = useMonthlyUsage(months); const orgLabels = useOrgLabels(); const rows = useMemo(() => usage.data?.rows ?? [], [usage.data]); @@ -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], @@ -132,7 +132,7 @@ export function Usage() { label="Total price" value={fmtMoney(pricing.summary.price)} icon={} - hint={`${pricingRegion} progressive customer tiers`} + hint={`${pricingRegion} progressive tiers · ${usage.data?.aws_region}`} /> ) : ( - + )} )} diff --git a/controlplane/admin/ui/src/pages/UsagePricing.test.tsx b/controlplane/admin/ui/src/pages/UsagePricing.test.tsx index c183b75a..57ff10f7 100644 --- a/controlplane/admin/ui/src/pages/UsagePricing.test.tsx +++ b/controlplane/admin/ui/src/pages/UsagePricing.test.tsx @@ -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"; @@ -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( - + , ); } +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(); @@ -41,6 +50,9 @@ 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", () => { @@ -48,11 +60,10 @@ describe("UsagePricing", () => { 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 () => { @@ -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; @@ -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( - {}} /> + , ); expect(screen.getByText(/no usage rows/i)).toBeInTheDocument(); diff --git a/controlplane/admin/ui/src/pages/UsagePricing.tsx b/controlplane/admin/ui/src/pages/UsagePricing.tsx index 581791a3..e4757f46 100644 --- a/controlplane/admin/ui/src/pages/UsagePricing.tsx +++ b/controlplane/admin/ui/src/pages/UsagePricing.tsx @@ -1,7 +1,7 @@ -import { useMemo, type ReactNode } from "react"; +import { useMemo, useState } from "react"; import { Link } from "react-router-dom"; +import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/states"; import { InfoTooltip } from "@/components/InfoTooltip"; import { OrgRef } from "@/components/OrgRef"; @@ -12,6 +12,7 @@ import { BINARY_UNITS_NOTE, GIB_HOURS_TOOLTIP, GROSS_MARGIN_TOOLTIP, + PRICE_AT_80_PERCENT_MARGIN_TOOLTIP, customerPriceTooltip, fmtMoney, orgTotals, @@ -20,46 +21,112 @@ import { } from "@/lib/pricing"; import type { MonthlyUsageRow } from "@/types/api"; -function HeaderWithTooltip({ - children, +function fmtMargin(profit: number, percent: number | null): string { + return `${percent == null ? "N/A" : `${percent.toFixed(1)}%`} (${fmtMoney(profit)} profit)`; +} + +type SortKey = + | "org" + | "storageGiBHours" + | "cost" + | "price" + | "priceAt80PercentMargin" + | "grossMarginPercent"; +type SortDirection = "asc" | "desc"; + +const orgCollator = new Intl.Collator("en", { numeric: true, sensitivity: "base" }); + +function SortableHeader({ + sortKey, label, - text, + activeKey, + direction, + onSort, + tooltipLabel, + tooltipText, + align = "left", }: { - children: ReactNode; + sortKey: SortKey; label: string; - text: string; + activeKey: SortKey; + direction: SortDirection; + onSort: (key: SortKey) => void; + tooltipLabel?: string; + tooltipText?: string; + align?: "left" | "right"; }) { + const active = activeKey === sortKey; + const SortIcon = active ? (direction === "asc" ? ArrowUp : ArrowDown) : ArrowUpDown; return ( - - {children} - - + + + + {tooltipLabel && tooltipText ? : null} + + ); } -function fmtMargin(profit: number, percent: number | null): string { - return `${percent == null ? "N/A" : `${percent.toFixed(1)}%`} (${fmtMoney(profit)} profit)`; -} - export function UsagePricing({ rows, labels, month, region, - onRegionChange, }: { rows: MonthlyUsageRow[]; labels?: Map; month: string; region: PricingRegion; - onRegionChange: (region: PricingRegion) => void; }) { + const [sort, setSort] = useState<{ key: SortKey; direction: SortDirection }>({ + key: "org", + direction: "asc", + }); const totals = useMemo(() => orgTotals(rows), [rows]); const pricing = useMemo(() => priceStorageByOrg(totals, month, region), [totals, month, region]); + const sortedRows = useMemo(() => { + const displayOrg = (orgId: string) => labels?.get(orgId)?.trim() || orgId; + const tieBreak = (a: (typeof pricing.rows)[number], b: (typeof pricing.rows)[number]) => + orgCollator.compare(displayOrg(a.orgId), displayOrg(b.orgId)) || + orgCollator.compare(a.orgId, b.orgId); + return [...pricing.rows].sort((a, b) => { + if (sort.key === "org") { + const comparison = tieBreak(a, b); + return sort.direction === "asc" ? comparison : -comparison; + } + const av = a[sort.key]; + const bv = b[sort.key]; + const aMissing = av == null || !Number.isFinite(av); + const bMissing = bv == null || !Number.isFinite(bv); + if (aMissing !== bMissing) return aMissing ? 1 : -1; + if (aMissing && bMissing) return tieBreak(a, b); + const comparison = (av as number) - (bv as number); + return comparison === 0 ? tieBreak(a, b) : sort.direction === "asc" ? comparison : -comparison; + }); + }, [labels, pricing.rows, sort]); + + const handleSort = (key: SortKey) => { + setSort((current) => + current.key === key + ? { key, direction: current.direction === "asc" ? "desc" : "asc" } + : { key, direction: key === "org" ? "asc" : "desc" }, + ); + }; return ( - +
Storage economics

@@ -67,21 +134,6 @@ export function UsagePricing({

{BINARY_UNITS_NOTE}

-
- Customer pricing - {(["US", "EU"] as const).map((value) => ( - - ))} -
{pricing.rows.length === 0 ? ( @@ -90,31 +142,66 @@ export function UsagePricing({ - Org - - - S3 GiB·h - - - - - Allocated AWS cost - - - - - Customer price ({region}) - - - - - Gross margin - - + + + + + + - {pricing.rows.map((row) => ( + {sortedRows.map((row) => ( @@ -124,6 +211,9 @@ export function UsagePricing({ {fmtUnits(row.storageGiBHours)} {fmtMoney(row.cost)} {fmtMoney(row.price)} + + {fmtMoney(row.priceAt80PercentMargin)} + {fmtMargin(row.grossProfit, row.grossMarginPercent)} @@ -134,6 +224,9 @@ export function UsagePricing({ {fmtUnits(pricing.summary.storageGiBHours)} {fmtMoney(pricing.summary.cost)} {fmtMoney(pricing.summary.price)} + + {fmtMoney(pricing.summary.priceAt80PercentMargin)} + {fmtMargin(pricing.summary.grossProfit, pricing.summary.grossMarginPercent)} diff --git a/controlplane/admin/ui/src/types/api.ts b/controlplane/admin/ui/src/types/api.ts index 59b5e0ac..c186a561 100644 --- a/controlplane/admin/ui/src/types/api.ts +++ b/controlplane/admin/ui/src/types/api.ts @@ -710,6 +710,8 @@ export interface MonthlyUsageRow { export interface MonthlyUsageResponse { from: string; months: number; + aws_region: string; + customer_pricing_region: "US" | "EU"; watermark_low: string | null; rows: MonthlyUsageRow[]; } diff --git a/controlplane/admin/usage_api.go b/controlplane/admin/usage_api.go index 3cbd06f8..c0dbb3bc 100644 --- a/controlplane/admin/usage_api.go +++ b/controlplane/admin/usage_api.go @@ -7,6 +7,7 @@ import ( "net/http" "sort" "strconv" + "strings" "time" "github.com/gin-gonic/gin" @@ -31,8 +32,9 @@ type usageStore interface { // watermark_low so the UI can caveat the window instead of silently implying // all-time totals. type usageAPIHandler struct { - store usageStore - now func() time.Time + store usageStore + now func() time.Time + awsRegion string } const ( @@ -64,8 +66,8 @@ type monthlyUsageResponseRow struct { // API gates the raw families behind RequireAdmin for exactly that reason, and // the monthly aggregate is no less sensitive. The gate travels with the route // (not RoleGate's path list), so renaming the route cannot downgrade it. -func registerUsageAPI(r gin.IRouter, store usageStore) { - h := &usageAPIHandler{store: store, now: time.Now} +func registerUsageAPI(r gin.IRouter, store usageStore, awsRegion string) { + h := &usageAPIHandler{store: store, now: time.Now, awsRegion: awsRegion} r.GET("/usage/monthly", RequireAdmin(), h.getMonthlyUsage) // Per-org daily series for the org detail page's usage charts. Same // RequireAdmin gate as the monthly read (per-team cost data). @@ -73,6 +75,13 @@ func registerUsageAPI(r gin.IRouter, store usageStore) { } func (h *usageAPIHandler) getMonthlyUsage(c *gin.Context) { + pricingRegion, ok := customerPricingRegionForAWSRegion(h.awsRegion) + if !ok { + c.JSON(http.StatusServiceUnavailable, gin.H{ + "error": "storage customer pricing is unsupported for the configured AWS region", + }) + return + } months := usageDefaultMonths if raw := c.Query("months"); raw != "" { n, err := strconv.Atoi(raw) @@ -152,13 +161,31 @@ func (h *usageAPIHandler) getMonthlyUsage(c *gin.Context) { watermarkLow = cursor.UTC().Format(time.RFC3339) } c.JSON(http.StatusOK, gin.H{ - "months": months, - "from": from.Format(time.RFC3339), - "watermark_low": watermarkLow, - "rows": rows, + "months": months, + "from": from.Format(time.RFC3339), + "aws_region": strings.TrimSpace(h.awsRegion), + "customer_pricing_region": pricingRegion, + "watermark_low": watermarkLow, + "rows": rows, }) } +// customerPricingRegionForAWSRegion maps the control plane's effective AWS +// region to the only two customer storage-price schedules we operate. Keep +// this strict: guessing a price for an unset or future region would make the +// admin economics view confidently wrong. +func customerPricingRegionForAWSRegion(awsRegion string) (string, bool) { + region := strings.ToLower(strings.TrimSpace(awsRegion)) + switch { + case strings.HasPrefix(region, "us-"): + return "US", true + case strings.HasPrefix(region, "eu-"): + return "EU", true + default: + return "", false + } +} + // dailyUsageResponseRow is one (date, team) line of an org's daily series — // the merged compute + storage families for that day. type dailyUsageResponseRow struct { diff --git a/controlplane/admin/usage_api_test.go b/controlplane/admin/usage_api_test.go index 03eb162c..68403f4e 100644 --- a/controlplane/admin/usage_api_test.go +++ b/controlplane/admin/usage_api_test.go @@ -42,6 +42,10 @@ func (s *fakeMonthlyUsageStore) ComputeBillingCursor() (time.Time, bool, error) // identity standing in for AuthMiddleware. role="" simulates an // unauthenticated caller (no identity in context). func setupUsageRouter(store usageStore, role Role) *gin.Engine { + return setupUsageRouterForRegion(store, role, "us-east-1") +} + +func setupUsageRouterForRegion(store usageStore, role Role, awsRegion string) *gin.Engine { gin.SetMode(gin.TestMode) r := gin.New() if role != "" { @@ -49,7 +53,7 @@ func setupUsageRouter(store usageStore, role Role) *gin.Engine { c.Set(ctxIdentityKey, &Identity{Email: "op@posthog.com", Role: role, Source: "sso"}) }) } - registerUsageAPI(r.Group("/api/v1"), store) + registerUsageAPI(r.Group("/api/v1"), store, awsRegion) return r } @@ -61,12 +65,40 @@ func setupUsageRouterAt(store usageStore, now func() time.Time) *gin.Engine { r.Use(func(c *gin.Context) { c.Set(ctxIdentityKey, &Identity{Email: "op@posthog.com", Role: RoleAdmin, Source: "sso"}) }) - h := &usageAPIHandler{store: store, now: now} + h := &usageAPIHandler{store: store, now: now, awsRegion: "us-east-1"} r.GET("/api/v1/usage/monthly", RequireAdmin(), h.getMonthlyUsage) r.GET("/api/v1/orgs/:id/usage/daily", RequireAdmin(), h.getDailyUsage) return r } +func TestMonthlyUsageReportsDeploymentPricingRegion(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + name string + awsRegion string + wantStatus int + wantPricing string + }{ + {name: "US production and development", awsRegion: "us-east-1", wantStatus: http.StatusOK, wantPricing: "US"}, + {name: "EU deployment", awsRegion: "eu-central-1", wantStatus: http.StatusOK, wantPricing: "EU"}, + {name: "missing region", awsRegion: "", wantStatus: http.StatusServiceUnavailable}, + {name: "unsupported region", awsRegion: "ap-southeast-1", wantStatus: http.StatusServiceUnavailable}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := setupUsageRouterForRegion(&fakeMonthlyUsageStore{}, RoleAdmin, tt.awsRegion) + code, body := usageRequest(t, r, "/api/v1/usage/monthly") + if code != tt.wantStatus { + t.Fatalf("status = %d, want %d: %v", code, tt.wantStatus, body) + } + if code == http.StatusOK && (body["aws_region"] != tt.awsRegion || body["customer_pricing_region"] != tt.wantPricing) { + t.Fatalf("deployment pricing fields = %v, want aws_region=%q customer_pricing_region=%q", body, tt.awsRegion, tt.wantPricing) + } + }) + } +} + // Usage data is cost data: the billing pull API gates the raw families behind // RequireAdmin, and this monthly aggregate is no less sensitive — it exposes // every org's per-team spend. Viewers (any @posthog.com SSO login) must NOT diff --git a/controlplane/multitenant.go b/controlplane/multitenant.go index 6d8b1dd3..4ce11194 100644 --- a/controlplane/multitenant.go +++ b/controlplane/multitenant.go @@ -719,7 +719,7 @@ func SetupMultiTenant( admin.AuditMiddleware(auditStore), admin.RoleGate(), ) - admin.RegisterAPI(api, store, adpt, liveFetcher) + admin.RegisterAPI(api, store, adpt, liveFetcher, cfg.K8s.AWSRegion) // gormStore implements both provisioning.Store (HTTP-shape operations) and // provisioning.TenantStore (service-credential mint + snapshot reload), so a // single wrapper serves both RegisterAPI arguments. liveFetcher doubles as diff --git a/k8s/README.md b/k8s/README.md index 99feb9cf..c51d2f69 100644 --- a/k8s/README.md +++ b/k8s/README.md @@ -67,6 +67,7 @@ Key flags for Kubernetes multitenant mode: | `--handover-drain-timeout` | `DUCKGRES_HANDOVER_DRAIN_TIMEOUT` | Max time to drain planned shutdowns/upgrades before forced exit (`15m` default in remote mode) | | `--sni-routing-mode` | `DUCKGRES_SNI_ROUTING_MODE` | Managed-hostname routing: `off`, `passthrough`, or `enforce`. Postgres uses requested dbname first; managed SNI must resolve to the same org, and SNI supplies the database only when dbname is empty | | `--managed-hostname-suffixes` | `DUCKGRES_MANAGED_HOSTNAME_SUFFIXES` | Comma-separated managed hostname suffixes such as `.dw.test.local` | +| `--aws-region` | `DUCKGRES_AWS_REGION` | Effective deployment AWS region (empty by default). Required for the admin storage-economics view: `us-*` selects US customer pricing and `eu-*` selects EU customer pricing; missing/unsupported values fail that view closed. | | `--k8s-worker-image` | `DUCKGRES_K8S_WORKER_IMAGE` | Docker image for worker pods | | `--k8s-worker-image-pull-policy` | `DUCKGRES_K8S_WORKER_IMAGE_PULL_POLICY` | Image pull policy (`Never`, `IfNotPresent`, `Always`) | | `--k8s-worker-service-account` | `DUCKGRES_K8S_WORKER_SERVICE_ACCOUNT` | Shared ServiceAccount name for worker pods (`duckgres-worker` default) |