From 3056b665152583e97c16113eee27a2a93285d364 Mon Sep 17 00:00:00 2001 From: Benjamin Knofe-Vider Date: Thu, 3 Sep 2026 11:37:49 +0200 Subject: [PATCH] Revamp admin storage usage economics --- controlplane/admin/README.md | 10 + .../admin/ui/src/components/InfoTooltip.tsx | 19 ++ controlplane/admin/ui/src/lib/pricing.test.ts | 127 +++++---- controlplane/admin/ui/src/lib/pricing.ts | 207 +++++++++++---- .../admin/ui/src/pages/OrgUsage.test.tsx | 43 ++- controlplane/admin/ui/src/pages/OrgUsage.tsx | 105 ++++---- .../admin/ui/src/pages/Usage.test.tsx | 53 ++-- controlplane/admin/ui/src/pages/Usage.tsx | 145 ++++------ .../admin/ui/src/pages/UsagePricing.test.tsx | 108 ++++---- .../admin/ui/src/pages/UsagePricing.tsx | 247 +++++++----------- 10 files changed, 582 insertions(+), 482 deletions(-) create mode 100644 controlplane/admin/ui/src/components/InfoTooltip.tsx diff --git a/controlplane/admin/README.md b/controlplane/admin/README.md index 16e628dc..4f7d73a5 100644 --- a/controlplane/admin/README.md +++ b/controlplane/admin/README.md @@ -90,6 +90,16 @@ Added for the console: | `POST /api/v1/operators` | admin | add/update an operator (`{email, role}`; last-admin demotion → 409) | | `DELETE /api/v1/operators/:email` | admin | remove an operator (removing the last admin → 409) | +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. + ### Cross-CP live-state aggregation (`live_aggregate.go` + `controlplane/live_aggregator.go`) Live session/query state is **in-memory per CP** — each replica only knows the diff --git a/controlplane/admin/ui/src/components/InfoTooltip.tsx b/controlplane/admin/ui/src/components/InfoTooltip.tsx new file mode 100644 index 00000000..02808a5c --- /dev/null +++ b/controlplane/admin/ui/src/components/InfoTooltip.tsx @@ -0,0 +1,19 @@ +import { CircleHelp } from "lucide-react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; + +export function InfoTooltip({ label, text }: { label: string; text: string }) { + return ( + + + + + {text} + + ); +} diff --git a/controlplane/admin/ui/src/lib/pricing.test.ts b/controlplane/admin/ui/src/lib/pricing.test.ts index 50849ab1..34845812 100644 --- a/controlplane/admin/ui/src/lib/pricing.test.ts +++ b/controlplane/admin/ui/src/lib/pricing.test.ts @@ -1,63 +1,98 @@ import { describe, expect, it } from "vitest"; -import { fmtMoney, orgTotals, parsePrice, scenarioCost, type PriceScenario } from "./pricing"; -import type { MonthlyUsageRow } from "@/types/api"; - -const ROWS: MonthlyUsageRow[] = [ - // acme: two teams in the same month → org totals must sum across teams. - { month: "2026-08", org_id: "acme", team_id: 5, schema_name: "team_5", cpu_seconds: 7200, memory_seconds: 3600, gib_seconds: 3600 }, - { month: "2026-08", org_id: "acme", team_id: 6, schema_name: "team_6", cpu_seconds: 600, memory_seconds: 600, gib_seconds: 7200 }, - { month: "2026-08", org_id: "globex", team_id: 9, schema_name: "team_9", cpu_seconds: 1200, memory_seconds: 0, gib_seconds: 0 }, -]; - -describe("orgTotals", () => { - it("sums usage units per org across teams", () => { - const totals = orgTotals(ROWS); - expect(totals).toHaveLength(2); - // Sorted by org id for a stable table. - expect(totals[0].orgId).toBe("acme"); - // acme: cpu (7200+600)/60 = 130 min; mem (3600+600)/60 = 70 GiB·min; storage (3600+7200)/3600 = 3 GiB·h. - expect(totals[0].cpuMinutes).toBeCloseTo(130); - expect(totals[0].memGiBMinutes).toBeCloseTo(70); - expect(totals[0].storageGiBHours).toBeCloseTo(3); - expect(totals[1].orgId).toBe("globex"); - expect(totals[1].cpuMinutes).toBeCloseTo(20); - }); - - it("returns an empty list for no rows", () => { - expect(orgTotals([])).toEqual([]); +import { + customerStoragePrice, + fmtMoney, + hoursInUTCMonth, + priceStorageByOrg, + storageGiBMonths, + type OrgUsageTotals, +} from "./pricing"; + +describe("storage pricing", () => { + it("converts GiB-hours using the actual UTC calendar month", () => { + expect(hoursInUTCMonth("2026-02")).toBe(672); + expect(hoursInUTCMonth("2028-02")).toBe(696); + expect(hoursInUTCMonth("2026-08")).toBe(744); + expect(storageGiBMonths(600 * 744, "2026-08")).toBe(600); }); -}); -describe("scenarioCost", () => { - it("multiplies each usage unit by its price", () => { - const s: PriceScenario = { id: "a", name: "A", cpuPerMin: 0.1, memPerGiBMin: 0.01, storagePerGiBH: 2 }; - // acme: 130×0.1 + 70×0.01 + 3×2 = 13 + 0.7 + 6 = 19.7 - const t = orgTotals(ROWS)[0]; - expect(scenarioCost(t, s)).toBeCloseTo(19.7); + it("applies the customer US tiers progressively with binary TiB boundaries", () => { + expect(customerStoragePrice(100, "US")).toBe(0); + expect(customerStoragePrice(600, "US")).toBeCloseTo(19.5); + // The supplied definition says 1 TiB = 1024 GiB. Consequently 37,200 + // GiB prices to $971.34; $969.90 would use contradictory decimal-TB cuts. + expect(customerStoragePrice(37_200, "US")).toBeCloseTo(971.34); }); - it("zero prices cost zero", () => { - const s: PriceScenario = { id: "z", name: "Z", cpuPerMin: 0, memPerGiBMin: 0, storagePerGiBH: 0 }; - expect(scenarioCost(orgTotals(ROWS)[1], s)).toBe(0); + it("applies the EU rates to the same progressive boundaries", () => { + expect(customerStoragePrice(600, "EU")).toBeCloseTo(21.45); }); -}); -describe("parsePrice", () => { - it("accepts non-negative numbers", () => { - expect(parsePrice("0.05")).toBeCloseTo(0.05); - expect(parsePrice("2")).toBe(2); - expect(parsePrice("")).toBe(0); + it.each([ + ["US", 100, 0.04], + ["US", 500, 0.035], + ["US", 1_024, 0.03], + ["US", 10_240, 0.0245], + ["US", 51_200, 0.0235], + ["EU", 100, 0.044], + ["EU", 500, 0.0385], + ["EU", 1_024, 0.033], + ["EU", 10_240, 0.027], + ["EU", 51_200, 0.026], + ] as const)("uses the %s marginal rate immediately above %d GiB-month", (region, boundary, rate) => { + expect(customerStoragePrice(boundary + 1, region) - customerStoragePrice(boundary, region)).toBeCloseTo(rate); }); - it("rejects NaN and negatives as 0", () => { - expect(parsePrice("abc")).toBe(0); - expect(parsePrice("-1")).toBe(0); + + it("uses aggregate us-east-1 S3 tiers and allocates cost back to orgs", () => { + const totals: OrgUsageTotals[] = [ + { orgId: "org-a", storageGiBHours: 40_000 * 744 }, + { orgId: "org-b", storageGiBHours: 20_000 * 744 }, + ]; + const priced = priceStorageByOrg(totals, "2026-08", "US"); + // Aggregate cost: first 50 TiB (51,200 GiB) at .023, remaining 8,800 at .022. + expect(priced.summary.cost).toBeCloseTo(51_200 * 0.023 + 8_800 * 0.022); + expect(priced.rows[0].cost + priced.rows[1].cost).toBeCloseTo(priced.summary.cost); + expect(priced.rows[0].cost / priced.rows[1].cost).toBeCloseTo(2); + }); + + it.each([ + [51_200, 0.022], + [512_000, 0.021], + ])("uses the next AWS marginal rate above %d aggregate GiB-month", (boundary, rate) => { + const costAt = (gibMonths: number) => + priceStorageByOrg([{ orgId: "org-a", storageGiBHours: gibMonths * 744 }], "2026-08", "US").summary.cost; + expect(costAt(boundary + 1) - costAt(boundary)).toBeCloseTo(rate); + }); + + it("prices each customer separately, then sums price, gross profit, and margin", () => { + const totals: OrgUsageTotals[] = [ + { orgId: "org-a", storageGiBHours: 600 * 744 }, + { orgId: "org-b", storageGiBHours: 50 * 744 }, + ]; + const priced = priceStorageByOrg(totals, "2026-08", "US"); + + expect(priced.rows[0]).toMatchObject({ orgId: "org-a", gibMonths: 600 }); + expect(priced.rows[0].cost).toBeCloseTo(13.8); + 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[1].cost).toBeCloseTo(1.15); + expect(priced.rows[1].price).toBe(0); + expect(priced.rows[1].grossProfit).toBeCloseTo(-1.15); + expect(priced.rows[1].grossMarginPercent).toBeNull(); + + expect(priced.summary.cost).toBeCloseTo(14.95); + expect(priced.summary.price).toBeCloseTo(19.5); + expect(priced.summary.grossProfit).toBeCloseTo(4.55); + expect(priced.summary.grossMarginPercent).toBeCloseTo(23.3333); }); }); describe("fmtMoney", () => { it("formats USD with two decimals", () => { expect(fmtMoney(19.7)).toBe("$19.70"); - expect(fmtMoney(0)).toBe("$0.00"); + expect(fmtMoney(-1.15)).toBe("-$1.15"); expect(fmtMoney(1234567.891)).toBe("$1,234,567.89"); }); }); diff --git a/controlplane/admin/ui/src/lib/pricing.ts b/controlplane/admin/ui/src/lib/pricing.ts index 603ffc91..af4817f1 100644 --- a/controlplane/admin/ui/src/lib/pricing.ts +++ b/controlplane/admin/ui/src/lib/pricing.ts @@ -1,69 +1,180 @@ -// Pricing-sensitivity logic for the Usage page's cost calculator. Pure -// functions — the feature is entirely client-side over the monthly usage -// endpoint's rows (per-team usage units per org per month), so the math here -// is the whole feature and is unit-tested directly. +// Storage pricing for the Usage page. Duckgres meters GiB-hours; both AWS +// cost and customer pricing are monthly, progressive schedules. import type { MonthlyUsageRow } from "@/types/api"; -// PriceScenario is one named set of unit prices ("what if CPU cost X?"). -// Prices are USD per usage unit, matching the units the Usage page displays. -export interface PriceScenario { - id: string; - name: string; - cpuPerMin: number; // $ per CPU-minute - memPerGiBMin: number; // $ per GiB·minute of memory - storagePerGiBH: number; // $ per GiB·hour of S3 -} +export type PricingRegion = "US" | "EU"; -// OrgUsageTotals is one org's month totals in display units (the sums of its -// teams' rows). export interface OrgUsageTotals { orgId: string; - cpuMinutes: number; - memGiBMinutes: number; storageGiBHours: number; } -// orgTotals aggregates per-team monthly rows into one totals line per org, -// sorted by org id for a stable table. +export interface OrgStoragePricing extends OrgUsageTotals { + gibMonths: number; + cost: number; + price: number; + grossProfit: number; + grossMarginPercent: number | null; +} + +export interface StoragePricingSummary { + storageGiBHours: number; + gibMonths: number; + cost: number; + price: number; + grossProfit: number; + grossMarginPercent: number | null; +} + +type StorageTier = { + upperBoundGiBMonths: number; + rate: number; +}; + +const TIB_IN_GIB = 1024; + +// Each customer tier applies only to the corresponding portion of one org's +// monthly usage. Bounds follow the supplied binary convention: 1 TB = 1024 GB. +const CUSTOMER_TIERS: Record = { + US: [ + { upperBoundGiBMonths: 100, rate: 0 }, + { upperBoundGiBMonths: 500, rate: 0.04 }, + { upperBoundGiBMonths: TIB_IN_GIB, rate: 0.035 }, + { upperBoundGiBMonths: 10 * TIB_IN_GIB, rate: 0.03 }, + { upperBoundGiBMonths: 50 * TIB_IN_GIB, rate: 0.0245 }, + { upperBoundGiBMonths: Number.POSITIVE_INFINITY, rate: 0.0235 }, + ], + EU: [ + { upperBoundGiBMonths: 100, rate: 0 }, + { upperBoundGiBMonths: 500, rate: 0.044 }, + { upperBoundGiBMonths: TIB_IN_GIB, rate: 0.0385 }, + { upperBoundGiBMonths: 10 * TIB_IN_GIB, rate: 0.033 }, + { upperBoundGiBMonths: 50 * TIB_IN_GIB, rate: 0.027 }, + { upperBoundGiBMonths: Number.POSITIVE_INFINITY, rate: 0.026 }, + ], +}; + +// Public S3 Standard list rates for us-east-1, verified 2026-09-03. AWS +// applies these tiers to combined regional usage, not independently per bucket. +const AWS_US_EAST_1_TIERS: StorageTier[] = [ + { upperBoundGiBMonths: 50 * TIB_IN_GIB, rate: 0.023 }, + { upperBoundGiBMonths: 500 * TIB_IN_GIB, rate: 0.022 }, + { upperBoundGiBMonths: Number.POSITIVE_INFINITY, rate: 0.021 }, +]; + +export const GIB_HOURS_TOOLTIP = + "S3 GiB·h measures storage over time, not current bucket size or a transfer rate. Duckgres samples tracked DuckLake file bytes every 30 minutes. Each sample contributes tracked GiB × 0.5 hours; for example, 10 GiB stored for 24 hours is 240 GiB·h. This view includes only samples still in the retained billing buffer."; + +export const AWS_COST_TOOLTIP = + "Estimated S3 Standard storage cost at public us-east-1 rates, not an AWS invoice or CUR charge. GiB·h is divided by the number of hours in the selected UTC month to get GiB-month. AWS tiers the combined monthly usage in this view: $0.023/GiB-month for the first 50 TiB, $0.022 for the next 450 TiB, and $0.021 thereafter. Storage capacity only; excludes requests, transfer, taxes, credits, negotiated discounts, and storage outside this view. Per-org cost is allocated in proportion to usage."; + +export function customerPriceTooltip(region: PricingRegion): string { + const rates = + region === "US" + ? "$0.040 from 100–500 GiB, $0.035 from 500 GiB–1 TiB, $0.030 from 1–10 TiB, $0.0245 from 10–50 TiB, and $0.0235 above 50 TiB" + : "$0.044 from 100–500 GiB, $0.0385 from 500 GiB–1 TiB, $0.033 from 1–10 TiB, $0.027 from 10–50 TiB, and $0.026 above 50 TiB"; + return `Estimated customer charge under the progressive ${region} monthly tiers. GiB·h is divided by the number of hours in the selected UTC month to get GiB-month. Each organization is priced independently, with its first 100 GiB-month free, then ${rates}. Each rate applies only to usage within that tier.`; +} + +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 BINARY_UNITS_NOTE = + "Storage follows AWS's binary convention: 1 GB = 2^30 bytes (1 GiB), and 1 TB = 2^40 bytes (1024 GB)."; + export function orgTotals(rows: MonthlyUsageRow[]): OrgUsageTotals[] { const byOrg = new Map(); - for (const r of rows) { - let t = byOrg.get(r.org_id); - if (!t) { - t = { orgId: r.org_id, cpuMinutes: 0, memGiBMinutes: 0, storageGiBHours: 0 }; - byOrg.set(r.org_id, t); + for (const row of rows) { + let total = byOrg.get(row.org_id); + if (!total) { + total = { orgId: row.org_id, storageGiBHours: 0 }; + byOrg.set(row.org_id, total); } - t.cpuMinutes += r.cpu_seconds / 60; - t.memGiBMinutes += r.memory_seconds / 60; - t.storageGiBHours += Number(r.gib_seconds) / 3600; + total.storageGiBHours += Number(row.gib_seconds) / 3600; } return [...byOrg.values()].sort((a, b) => a.orgId.localeCompare(b.orgId)); } -// scenarioCost prices one org's month under one scenario. -export function scenarioCost(t: OrgUsageTotals, s: PriceScenario): number { - return t.cpuMinutes * s.cpuPerMin + t.memGiBMinutes * s.memPerGiBMin + t.storageGiBHours * s.storagePerGiBH; +export function hoursInUTCMonth(month: string): number { + const match = /^(\d{4})-(\d{2})$/.exec(month); + if (!match) throw new Error(`invalid UTC month: ${month}`); + const year = Number(match[1]); + const monthIndex = Number(match[2]) - 1; + if (monthIndex < 0 || monthIndex > 11) throw new Error(`invalid UTC month: ${month}`); + return (Date.UTC(year, monthIndex + 1, 1) - Date.UTC(year, monthIndex, 1)) / 3_600_000; +} + +export function storageGiBMonths(storageGiBHours: number, month: string): number { + return Math.max(0, storageGiBHours) / hoursInUTCMonth(month); +} + +function progressiveCharge(quantity: number, tiers: StorageTier[]): number { + let charge = 0; + let lowerBound = 0; + const usage = Math.max(0, quantity); + for (const tier of tiers) { + const inTier = Math.min(Math.max(usage - lowerBound, 0), tier.upperBoundGiBMonths - lowerBound); + charge += inTier * tier.rate; + lowerBound = tier.upperBoundGiBMonths; + if (usage <= lowerBound) break; + } + return charge; } -// Grounded defaults for a fresh scenario, so the calculator is useful on -// first open — roughly EC2 on-demand m6i/r6gd-class economics (≈$0.024/vCPU·h -// and ≈$0.006/GiB·h RAM, i.e. half the instance price attributed to each) -// plus S3 standard storage (≈$0.023/GiB·mo ≈ $0.00003/GiB·h). These are -// editable starting points for sensitivity analysis, not PostHog pricing. -export const DEFAULT_CPU_PER_MIN = 0.0004; -export const DEFAULT_MEM_PER_GIB_MIN = 0.0001; -export const DEFAULT_STORAGE_PER_GIB_H = 0.00003; - -// parsePrice reads a price input: non-negative finite numbers pass through, -// anything else (empty, NaN, negative) is 0 — a half-typed input must never -// make a cost cell NaN. -export function parsePrice(raw: string): number { - const v = Number.parseFloat(raw); - return Number.isFinite(v) && v >= 0 ? v : 0; +export function customerStoragePrice(gibMonths: number, region: PricingRegion): number { + return progressiveCharge(gibMonths, CUSTOMER_TIERS[region]); +} + +function awsStorageCost(gibMonths: number): number { + return progressiveCharge(gibMonths, AWS_US_EAST_1_TIERS); +} + +export function priceStorageByOrg( + totals: OrgUsageTotals[], + month: string, + region: PricingRegion, +): { rows: OrgStoragePricing[]; summary: StoragePricingSummary } { + const withMonths = totals.map((total) => ({ ...total, gibMonths: storageGiBMonths(total.storageGiBHours, month) })); + const totalGiBMonths = withMonths.reduce((sum, row) => sum + row.gibMonths, 0); + const totalCost = awsStorageCost(totalGiBMonths); + + const rows = withMonths.map((row): OrgStoragePricing => { + // AWS tiers combined regional usage. Allocate that aggregate list cost by + // metered usage so per-org cost and margin rows add exactly to the total. + const cost = totalGiBMonths === 0 ? 0 : totalCost * (row.gibMonths / totalGiBMonths); + const price = customerStoragePrice(row.gibMonths, region); + const grossProfit = price - cost; + return { + ...row, + cost, + price, + grossProfit, + grossMarginPercent: price > 0 ? (grossProfit / price) * 100 : null, + }; + }); + + const storageGiBHours = totals.reduce((sum, row) => sum + row.storageGiBHours, 0); + const price = rows.reduce((sum, row) => sum + row.price, 0); + const grossProfit = price - totalCost; + return { + rows, + summary: { + storageGiBHours, + gibMonths: totalGiBMonths, + cost: totalCost, + price, + grossProfit, + grossMarginPercent: price > 0 ? (grossProfit / price) * 100 : null, + }, + }; } -// fmtMoney formats a USD amount with grouping and two decimals. -export function fmtMoney(n: number): string { - return n.toLocaleString("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 2 }); +export function fmtMoney(value: number): string { + return value.toLocaleString("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); } diff --git a/controlplane/admin/ui/src/pages/OrgUsage.test.tsx b/controlplane/admin/ui/src/pages/OrgUsage.test.tsx index 95cc92c7..0a6d48b8 100644 --- a/controlplane/admin/ui/src/pages/OrgUsage.test.tsx +++ b/controlplane/admin/ui/src/pages/OrgUsage.test.tsx @@ -1,6 +1,7 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { fireEvent, render, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; +import { TooltipProvider } from "@/components/ui/tooltip"; import type { DailyUsageResponse } from "@/types/api"; // Mock data + identity hooks: render OrgUsageSection with a controlled daily @@ -35,27 +36,32 @@ const RESPONSE: DailyUsageResponse = { function renderSection() { return render( - + + + , ); } describe("OrgUsageSection", () => { + afterEach(() => { + vi.useRealTimers(); + }); + beforeEach(() => { vi.clearAllMocks(); identity.useIdentity.mockReturnValue({ isAdmin: true, me: { email: "op@posthog.com", role: "admin", source: "sso" } }); hooks.useOrgDailyUsage.mockReturnValue(ok(RESPONSE)); }); - it("renders the three usage charts with window totals", () => { + it("renders one org-level storage chart without compute or team series", () => { renderSection(); - expect(screen.getByText("CPU-minutes")).toBeInTheDocument(); - expect(screen.getByText("Memory GiB·minutes")).toBeInTheDocument(); expect(screen.getByText("S3 GiB·hours")).toBeInTheDocument(); - // Window totals: CPU (7200+60+600)/60 = 131; mem (3600+60+600)/60 = 71; S3 (3600+7200)/3600 = 3. - expect(screen.getByText(/131 total/)).toBeInTheDocument(); - expect(screen.getByText(/71 total/)).toBeInTheDocument(); expect(screen.getByText(/3 total/)).toBeInTheDocument(); + expect(screen.queryByText("CPU-minutes")).not.toBeInTheDocument(); + expect(screen.queryByText("Memory GiB·minutes")).not.toBeInTheDocument(); + expect(screen.queryByText("team_5")).not.toBeInTheDocument(); + expect(screen.queryByText("team_6")).not.toBeInTheDocument(); }); it("queries with the selected period when a period button is clicked", () => { @@ -65,6 +71,26 @@ describe("OrgUsageSection", () => { expect(hooks.useOrgDailyUsage).toHaveBeenCalledWith("acme", 30); }); + it("offers UTC week-to-date and month-to-date presets", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-19T12:00:00Z")); // Wednesday, day 19. + renderSection(); + + fireEvent.click(screen.getByRole("button", { name: "WTD" })); + expect(hooks.useOrgDailyUsage).toHaveBeenCalledWith("acme", 3); + + fireEvent.click(screen.getByRole("button", { name: "MTD" })); + expect(hooks.useOrgDailyUsage).toHaveBeenCalledWith("acme", 19); + }); + + it("explains that GiB·h is storage over time", async () => { + renderSection(); + fireEvent.focus(screen.getByRole("button", { name: "Explain S3 GiB·h" })); + expect( + (await screen.findAllByText(/storage over time, not current bucket size or a transfer rate/i)).length, + ).toBeGreaterThan(0); + }); + it("renders an empty state when the org has no usage in the window", () => { hooks.useOrgDailyUsage.mockReturnValue(ok({ ...RESPONSE, rows: [] })); renderSection(); @@ -75,6 +101,7 @@ describe("OrgUsageSection", () => { hooks.useOrgDailyUsage.mockReturnValue(ok({ ...RESPONSE, watermark_low: "2026-08-12T00:00:00Z" })); renderSection(); expect(screen.getByText(/billed and removed/i)).toBeInTheDocument(); + expect(screen.queryByText(/garbage-collected/i)).not.toBeInTheDocument(); }); it("renders nothing for viewers (cost data is admin-only)", () => { diff --git a/controlplane/admin/ui/src/pages/OrgUsage.tsx b/controlplane/admin/ui/src/pages/OrgUsage.tsx index 8a908537..d941c27a 100644 --- a/controlplane/admin/ui/src/pages/OrgUsage.tsx +++ b/controlplane/admin/ui/src/pages/OrgUsage.tsx @@ -1,58 +1,57 @@ import { useMemo, useState } from "react"; -import { Bar, BarChart, CartesianGrid, Legend, Tooltip as RTooltip, ResponsiveContainer, XAxis, YAxis } from "recharts"; +import { Bar, BarChart, CartesianGrid, Tooltip as RTooltip, ResponsiveContainer, XAxis, YAxis } from "recharts"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { EmptyState, ErrorState, LoadingState } from "@/components/states"; +import { InfoTooltip } from "@/components/InfoTooltip"; import { useIdentity } from "@/components/IdentityProvider"; import { useOrgDailyUsage } from "@/hooks/useApi"; -import { hashColor } from "@/lib/colors"; import { fmtTime, fmtUnits } from "@/lib/format"; +import { GIB_HOURS_TOOLTIP } from "@/lib/pricing"; import { cn } from "@/lib/utils"; import type { DailyUsageRow } from "@/types/api"; -const PERIODS = [7, 14, 30]; +type PeriodKey = "7d" | "14d" | "30d" | "wtd" | "mtd"; -type Metric = { - key: string; - title: string; - // derive the chart value (display units) from a raw daily row - value: (r: DailyUsageRow) => number; - unit: string; -}; +function periodDays(period: PeriodKey, now = new Date()): number { + if (period === "wtd") { + const weekday = now.getUTCDay(); + return weekday === 0 ? 7 : weekday; + } + if (period === "mtd") return now.getUTCDate(); + return Number.parseInt(period, 10); +} -const METRICS: Metric[] = [ - { key: "cpu", title: "CPU-minutes", value: (r) => r.cpu_seconds / 60, unit: "CPU-min" }, - { key: "mem", title: "Memory GiB·minutes", value: (r) => r.memory_seconds / 60, unit: "GiB·min" }, - { key: "storage", title: "S3 GiB·hours", value: (r) => Number(r.gib_seconds) / 3600, unit: "GiB·h" }, +const PERIODS: { key: PeriodKey; label: string }[] = [ + { key: "7d", label: "7d" }, + { key: "14d", label: "14d" }, + { key: "30d", label: "30d" }, + { key: "wtd", label: "WTD" }, + { key: "mtd", label: "MTD" }, ]; -function teamLabel(r: DailyUsageRow): string { - return r.schema_name ?? `team ${r.team_id}`; -} - -// Pivot rows into recharts shape: one object per date, one key per team. -function pivot(rows: DailyUsageRow[], metric: Metric) { - const teams = [...new Set(rows.map(teamLabel))].sort(); - const byDate = new Map>(); +// The API retains an informational team stamp, but storage belongs to the org. +// Collapse all stamps into one value per UTC date. +function dailyStorage(rows: DailyUsageRow[]) { + const byDate = new Map(); for (const r of rows) { - let d = byDate.get(r.date); - if (!d) { - d = { date: r.date }; - byDate.set(r.date, d); - } - const k = teamLabel(r); - d[k] = ((d[k] as number) ?? 0) + metric.value(r); + byDate.set(r.date, (byDate.get(r.date) ?? 0) + Number(r.gib_seconds) / 3600); } - return { data: [...byDate.values()].sort((a, b) => String(a.date).localeCompare(String(b.date))), teams }; + return [...byDate.entries()] + .map(([date, storage]) => ({ date, storage })) + .sort((a, b) => a.date.localeCompare(b.date)); } -function UsageChart({ metric, rows }: { metric: Metric; rows: DailyUsageRow[] }) { - const { data, teams } = useMemo(() => pivot(rows, metric), [rows, metric]); - const total = useMemo(() => rows.reduce((s, r) => s + metric.value(r), 0), [rows, metric]); +function UsageChart({ rows }: { rows: DailyUsageRow[] }) { + const data = useMemo(() => dailyStorage(rows), [rows]); + const total = useMemo(() => data.reduce((sum, row) => sum + row.storage, 0), [data]); return ( - {metric.title} + + S3 GiB·hours + +

{fmtUnits(total)} total in window

@@ -76,12 +75,9 @@ function UsageChart({ metric, rows }: { metric: Metric; rows: DailyUsageRow[] }) borderRadius: 8, fontSize: 12, }} - formatter={(v: number, name) => [`${fmtUnits(v)} ${metric.unit}`, name]} + formatter={(v: number) => [`${fmtUnits(v)} GiB·h`, "S3 storage"]} /> - - {teams.map((t) => ( - - ))} + )} @@ -90,13 +86,14 @@ function UsageChart({ metric, rows }: { metric: Metric; rows: DailyUsageRow[] }) ); } -// OrgUsageSection renders the org's daily usage charts (CPU / memory / S3, -// stacked by team) over a selectable window. Cost data is admin-only — +// OrgUsageSection renders the org's daily S3 usage over a selectable window. +// Cost data is admin-only — // viewers get nothing at all (the API 403s them anyway; this keeps the page // clean and avoids the wasted request). export function OrgUsageSection({ orgId }: { orgId: string }) { const { isAdmin } = useIdentity(); - const [days, setDays] = useState(14); + const [period, setPeriod] = useState("14d"); + const days = periodDays(period); const usage = useOrgDailyUsage(orgId, days); if (!isAdmin) return null; @@ -108,19 +105,19 @@ export function OrgUsageSection({ orgId }: { orgId: string }) {
Usage

- Daily compute and storage per team, summed over the retained billing buffer. + Daily S3 storage-time (GiB·h) for this organization, summed over the retained billing buffer.

- {PERIODS.map((n) => ( + {PERIODS.map(({ key, label }) => ( ))}
@@ -128,8 +125,8 @@ export function OrgUsageSection({ orgId }: { orgId: string }) { {usage.data?.watermark_low && (

- Usage at or before {fmtTime(usage.data.watermark_low)} has been billed and removed from the buffer, and - buckets older than 30 days are garbage-collected — the left edge of a long window may be partial. + Usage at or before {fmtTime(usage.data.watermark_low)} has been billed and removed from the buffer, so the + left edge of the selected period may be partial.

)} {usage.isError ? ( @@ -139,11 +136,7 @@ export function OrgUsageSection({ orgId }: { orgId: string }) { ) : rows.length === 0 ? ( ) : ( -
- {METRICS.map((m) => ( - - ))} -
+ )}
diff --git a/controlplane/admin/ui/src/pages/Usage.test.tsx b/controlplane/admin/ui/src/pages/Usage.test.tsx index ef433dbd..3a141bf5 100644 --- a/controlplane/admin/ui/src/pages/Usage.test.tsx +++ b/controlplane/admin/ui/src/pages/Usage.test.tsx @@ -1,6 +1,7 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { render, screen, within } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; +import { TooltipProvider } from "@/components/ui/tooltip"; import type { MonthlyUsageResponse } from "@/types/api"; // Mock the data hooks so we can render Usage with a controlled monthly @@ -17,15 +18,18 @@ vi.mock("@/components/IdentityProvider", () => identity); import { Usage } from "./Usage"; const ok = (data: T) => ({ data, isSuccess: true, isLoading: false, isError: false, refetch: vi.fn() }); +const AUGUST_HOURS = 744; +const gibSeconds = (gibMonths: number) => gibMonths * AUGUST_HOURS * 3600; const RESPONSE: MonthlyUsageResponse = { from: "2026-06-01T00:00:00Z", months: 3, watermark_low: "2026-07-20T00:00:00Z", rows: [ - // 7200 CPU-seconds = 120 CPU-minutes; 3600 GiB-seconds storage = 1 GiB-hour. - { month: "2026-08", org_id: "acme", team_id: 5, schema_name: "team_5", cpu_seconds: 7200, memory_seconds: 3600, gib_seconds: 3600 }, - { month: "2026-08", org_id: "acme", team_id: 6, schema_name: "team_6", cpu_seconds: 60, memory_seconds: 120, gib_seconds: 0 }, + // Two historical team stamps for the same org must become one storage row. + { 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-07", org_id: "acme", team_id: 5, schema_name: "team_5", cpu_seconds: 600, memory_seconds: 600, gib_seconds: 0 }, ], }; @@ -33,7 +37,9 @@ const RESPONSE: MonthlyUsageResponse = { function renderPage() { render( - + + + , ); } @@ -50,37 +56,46 @@ describe("Usage page", () => { identity.useIdentity.mockReturnValue({ isAdmin: false, me: { email: "v@posthog.com", role: "viewer", source: "sso" } }); renderPage(); expect(screen.getByText(/admin only/i)).toBeInTheDocument(); - expect(screen.queryByText("team_5")).not.toBeInTheDocument(); + expect(screen.queryByRole("columnheader", { name: "Org" })).not.toBeInTheDocument(); + expect(screen.queryByText(/per-team/i)).not.toBeInTheDocument(); }); - it("defaults to the latest month and shows per-team rows with derived units", () => { + it("shows storage only and aggregates historical team rows into one org row", () => { hooks.useMonthlyUsage.mockReturnValue(ok(RESPONSE)); renderPage(); - // Latest month (2026-08) is selected by default: both acme teams render. - expect(screen.getByText("team_5")).toBeInTheDocument(); - expect(screen.getByText("team_6")).toBeInTheDocument(); - // CPU minutes: 7200s -> 120. - expect(screen.getByText("120")).toBeInTheDocument(); - // Storage: 3600 GiB-seconds -> 1 GiB-hour. - expect(screen.getAllByText("1").length).toBeGreaterThan(0); - // The July row is NOT in the default view. - expect(screen.queryByText("10")).not.toBeInTheDocument(); // 600s = 10 min (July only) + expect(screen.getByText(/storage-time.*retained billing buffer/i)).toBeInTheDocument(); + expect(screen.queryByRole("columnheader", { name: "Team" })).not.toBeInTheDocument(); + expect(screen.queryByRole("columnheader", { name: /CPU/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("columnheader", { name: /Memory/i })).not.toBeInTheDocument(); + expect(screen.queryByText("team_5")).not.toBeInTheDocument(); + expect(screen.queryByText("team_6")).not.toBeInTheDocument(); + + expect(screen.getAllByRole("table")).toHaveLength(1); + const usageTable = screen.getByRole("table"); + expect(within(usageTable).getByRole("columnheader", { name: /allocated aws cost/i })).toBeInTheDocument(); + const acmeRow = within(usageTable).getByText("acme").closest("tr")!; + expect(within(acmeRow).getByText("446,400")).toBeInTheDocument(); }); - it("shows month totals for the selected month", () => { + it("shows total cost, customer price, and gross margin without compute cards", () => { hooks.useMonthlyUsage.mockReturnValue(ok(RESPONSE)); renderPage(); - // August total CPU-minutes = 120 + 1 = 121 (scoped to the stat card — the - // pricing table shows the same org sum). - expect(within(screen.getByTestId("stat-CPU-min")).getByText("121")).toBeInTheDocument(); + 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 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("renders the retention caveat when billing has acked a watermark", () => { hooks.useMonthlyUsage.mockReturnValue(ok(RESPONSE)); renderPage(); expect(screen.getByText(/billed and removed/i)).toBeInTheDocument(); + expect(screen.queryByText(/garbage-collected/i)).not.toBeInTheDocument(); }); it("renders an empty state when there is no usage", () => { diff --git a/controlplane/admin/ui/src/pages/Usage.tsx b/controlplane/admin/ui/src/pages/Usage.tsx index b7a9b640..817c9104 100644 --- a/controlplane/admin/ui/src/pages/Usage.tsx +++ b/controlplane/admin/ui/src/pages/Usage.tsx @@ -1,10 +1,7 @@ import { useMemo, useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { type ColumnDef } from "@tanstack/react-table"; -import { Coins, Cpu, Database, MemoryStick, ShieldAlert } from "lucide-react"; +import { Coins, ShieldAlert } from "lucide-react"; import { PageBody, PageHeader } from "@/components/AppShell"; -import { DataTable } from "@/components/DataTable"; -import { OrgRef } from "@/components/OrgRef"; +import { InfoTooltip } from "@/components/InfoTooltip"; import { StatCard } from "@/components/StatCard"; import { UsagePricing } from "@/pages/UsagePricing"; import { Card } from "@/components/ui/card"; @@ -12,12 +9,16 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { EmptyState, ErrorState, TableSkeleton } from "@/components/states"; import { useIdentity } from "@/components/IdentityProvider"; import { useMonthlyUsage, useOrgLabels } from "@/hooks/useApi"; -import { fmtTime, fmtUnits } from "@/lib/format"; -import type { MonthlyUsageRow } from "@/types/api"; - -const cpuMinutes = (r: MonthlyUsageRow) => r.cpu_seconds / 60; -const memGiBMinutes = (r: MonthlyUsageRow) => r.memory_seconds / 60; -const storageGiBHours = (r: MonthlyUsageRow) => Number(r.gib_seconds) / 3600; +import { fmtTime } from "@/lib/format"; +import { + AWS_COST_TOOLTIP, + GROSS_MARGIN_TOOLTIP, + customerPriceTooltip, + fmtMoney, + orgTotals, + priceStorageByOrg, + type PricingRegion, +} from "@/lib/pricing"; function currentMonth(): string { const d = new Date(); @@ -26,8 +27,8 @@ function currentMonth(): string { export function Usage() { const { isAdmin } = useIdentity(); - const navigate = useNavigate(); 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]); @@ -47,73 +48,26 @@ export function Usage() { : (monthOptions.find((m) => rows.some((r) => r.month === m)) ?? currentMonth()); const monthRows = useMemo(() => rows.filter((r) => r.month === month), [rows, month]); - const totals = useMemo( - () => - monthRows.reduce( - (acc, r) => ({ - cpu: acc.cpu + cpuMinutes(r), - mem: acc.mem + memGiBMinutes(r), - storage: acc.storage + storageGiBHours(r), - }), - { cpu: 0, mem: 0, storage: 0 }, - ), - [monthRows], - ); - - const columns = useMemo[]>( - () => [ - { - accessorKey: "org_id", - header: "Org", - cell: ({ row }) => , - }, - { - id: "team", - header: "Team", - accessorFn: (r) => r.schema_name ?? `team ${r.team_id}`, - cell: ({ row }) => { - const r = row.original; - return ( -
- {r.schema_name ?? `team ${r.team_id}`} - id {r.team_id} -
- ); - }, - }, - { - id: "cpu_minutes", - header: "CPU-min", - accessorFn: cpuMinutes, - cell: ({ getValue }) => {fmtUnits(getValue() as number)}, - }, - { - id: "mem_gib_minutes", - header: "Memory GiB·min", - accessorFn: memGiBMinutes, - cell: ({ getValue }) => {fmtUnits(getValue() as number)}, - }, - { - id: "storage_gib_hours", - header: "S3 GiB·h", - accessorFn: storageGiBHours, - cell: ({ getValue }) => {fmtUnits(getValue() as number)}, - }, - ], - [orgLabels], + // Storage is org-scoped. The API still carries its historical informational + // 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 pricing = useMemo( + () => priceStorageByOrg(orgRows, month, pricingRegion), + [orgRows, month, pricingRegion], ); - // Per-team cost data is admin-only (the API enforces RequireAdmin; this is + // Organization cost data is admin-only (the API enforces RequireAdmin; this is // just the friendly notice, matching the Operators page). if (!isAdmin) { return ( <> - + } title="Admin only" - description="Per-team usage and cost data requires the admin role." + description="Organization usage and cost data requires the admin role." /> @@ -124,7 +78,7 @@ export function Usage() { <> update(s.id, { name: e.target.value })} - className="h-7 w-32 text-xs font-medium" - aria-label="Scenario name" - /> - {scenarios.length > 1 && ( - - )} - -
- {( - [ - ["cpuPerMin", "$/CPU-min"], - ["memPerGiBMin", "$/GiB·min"], - ["storagePerGiBH", "$/GiB·h"], - ] as const - ).map(([field, label]) => ( - - ))} -
- +
+ Customer pricing + {(["US", "EU"] as const).map((value) => ( + ))}
- - {totals.length === 0 ? ( - + + + {pricing.rows.length === 0 ? ( + ) : ( Org - CPU-min - GiB·min - GiB·h - {scenarios.map((s) => ( - - {s.name}/mo - - ))} + + + S3 GiB·h + + + + + Allocated AWS cost + + + + + Customer price ({region}) + + + + + Gross margin + + - {totals.map((t) => ( - + {pricing.rows.map((row) => ( + - - + + - {fmtUnits(t.cpuMinutes)} - {fmtUnits(t.memGiBMinutes)} - {fmtUnits(t.storageGiBHours)} - {scenarios.map((s) => ( - - {fmtMoney(scenarioCost(t, s))} - - ))} + {fmtUnits(row.storageGiBHours)} + {fmtMoney(row.cost)} + {fmtMoney(row.price)} + + {fmtMargin(row.grossProfit, row.grossMarginPercent)} + ))} All orgs - {fmtUnits(grand.cpuMinutes)} - {fmtUnits(grand.memGiBMinutes)} - {fmtUnits(grand.storageGiBHours)} - {scenarios.map((s) => ( - - {fmtMoney(scenarioCost(grand, s))} - - ))} + {fmtUnits(pricing.summary.storageGiBHours)} + {fmtMoney(pricing.summary.cost)} + {fmtMoney(pricing.summary.price)} + + {fmtMargin(pricing.summary.grossProfit, pricing.summary.grossMarginPercent)} +