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
10 changes: 10 additions & 0 deletions controlplane/admin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions controlplane/admin/ui/src/components/InfoTooltip.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={label}
className="inline-flex text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<CircleHelp className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent className="max-w-sm leading-relaxed">{text}</TooltipContent>
</Tooltip>
);
}
127 changes: 81 additions & 46 deletions controlplane/admin/ui/src/lib/pricing.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading
Loading