Skip to content

Commit d8158af

Browse files
0skiTrigger.dev RepoOps
authored andcommitted
feat(webapp,plugins): add an admin-only usage page that stores settled usage days, with usage, project and add-on tabs
Add an unlinked, admin-only billing usage page that shows an organization's monthly usage, credits and add-ons from the billing plugin, with a breakdown by usage type, machine, environment and region, and a per-project view of compute time and runs. Past days are read once and stored, and the page fills in missing days progressively. Mono-RevId: 99478590defa16178bc8d9c40977841d59bc107d
1 parent 034f10c commit d8158af

38 files changed

Lines changed: 4184 additions & 14 deletions
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import type { BillingAddOns } from "@trigger.dev/billing";
2+
import { MetricsLayout } from "~/components/layout/MetricsLayout";
3+
import { Header2 } from "~/components/primitives/Headers";
4+
import { Paragraph } from "~/components/primitives/Paragraph";
5+
import {
6+
Table,
7+
TableBlankRow,
8+
TableBody,
9+
TableCell,
10+
TableHeader,
11+
TableHeaderCell,
12+
TableRow,
13+
} from "~/components/primitives/Table";
14+
import {
15+
billingDisplayName,
16+
formatAddOnQuantity,
17+
formatUsageDollars,
18+
USD_CENTS_CREDIT_TYPE,
19+
} from "~/utils/billingUsageFormat";
20+
import { formatUsageDay } from "~/utils/billingUsagePeriod";
21+
import { UsageFooterRow } from "./UsageBreakdownTable";
22+
23+
export function AddOnsTab({ addOns }: { addOns: BillingAddOns }) {
24+
const minorUnits = (addOns.creditType ?? USD_CENTS_CREDIT_TYPE).currency?.minorUnitsPerUnit ?? 1;
25+
const money = (amount: number) => formatUsageDollars(amount / minorUnits);
26+
const nextTotal = addOns.next.reduce((sum, line) => sum + line.total, 0);
27+
28+
return (
29+
<>
30+
<MetricsLayout.Content
31+
toolbar={
32+
<Header2 className="pl-1.5">
33+
Add-ons on the {formatUsageDay(addOns.invoiceDate)} invoice
34+
</Header2>
35+
}
36+
>
37+
<Table>
38+
<TableHeader>
39+
<TableRow>
40+
<TableHeaderCell>Add-on</TableHeaderCell>
41+
<TableHeaderCell alignment="right">Quantity</TableHeaderCell>
42+
<TableHeaderCell alignment="right">Unit price</TableHeaderCell>
43+
<TableHeaderCell alignment="right">Amount</TableHeaderCell>
44+
</TableRow>
45+
</TableHeader>
46+
<TableBody>
47+
{addOns.next.length === 0 ? (
48+
<TableBlankRow colSpan={4}>
49+
<Paragraph className="w-auto" variant="base/bright">
50+
No add-ons on the next invoice
51+
</Paragraph>
52+
</TableBlankRow>
53+
) : (
54+
addOns.next.map((line) => (
55+
<TableRow key={line.productId}>
56+
<TableCell>{line.name}</TableCell>
57+
<TableCell alignment="right" className="tabular-nums">
58+
{formatAddOnQuantity(line.name, line.quantity)}
59+
</TableCell>
60+
<TableCell alignment="right" className="tabular-nums text-text-dimmed">
61+
{line.unitPrice === null ? "–" : money(line.unitPrice)}
62+
</TableCell>
63+
<TableCell alignment="right" className="tabular-nums">
64+
{money(line.total)}
65+
</TableCell>
66+
</TableRow>
67+
))
68+
)}
69+
{addOns.next.length > 0 && (
70+
<UsageFooterRow label="Total" cells={[null, null, money(nextTotal)]} emphasis />
71+
)}
72+
</TableBody>
73+
</Table>
74+
</MetricsLayout.Content>
75+
{addOns.charged.length > 0 && (
76+
<MetricsLayout.Content
77+
toolbar={<Header2 className="pl-1.5">Charged this month, prorated</Header2>}
78+
>
79+
<Table>
80+
<TableHeader>
81+
<TableRow>
82+
<TableHeaderCell>Charge or credit</TableHeaderCell>
83+
<TableHeaderCell>Date</TableHeaderCell>
84+
<TableHeaderCell alignment="right">Amount</TableHeaderCell>
85+
</TableRow>
86+
</TableHeader>
87+
<TableBody>
88+
{addOns.charged.map((charge) => (
89+
<TableRow key={`${charge.date}|${charge.name}`}>
90+
<TableCell className="text-text-dimmed">
91+
{billingDisplayName(charge.name)}
92+
</TableCell>
93+
<TableCell className="text-text-dimmed">{formatUsageDay(charge.date)}</TableCell>
94+
<TableCell alignment="right" className="tabular-nums text-text-dimmed">
95+
{charge.total < 0 ? `−${money(-charge.total)}` : money(charge.total)}
96+
</TableCell>
97+
</TableRow>
98+
))}
99+
</TableBody>
100+
</Table>
101+
</MetricsLayout.Content>
102+
)}
103+
</>
104+
);
105+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import {
2+
ArrowPathIcon,
3+
DocumentTextIcon,
4+
ExclamationTriangleIcon,
5+
} from "@heroicons/react/20/solid";
6+
import { useRevalidator } from "@remix-run/react";
7+
import type { BillingUsageError } from "@trigger.dev/billing";
8+
import { Button } from "~/components/primitives/Buttons";
9+
import { InfoPanel } from "~/components/primitives/InfoPanel";
10+
import { billingUsageErrorState } from "~/utils/billingUsageFormat";
11+
12+
const ICONS = {
13+
empty: DocumentTextIcon,
14+
retry: ArrowPathIcon,
15+
error: ExclamationTriangleIcon,
16+
};
17+
18+
export function BillingUsageErrorPanel({
19+
error,
20+
retryable,
21+
}: {
22+
error: BillingUsageError;
23+
retryable: boolean;
24+
}) {
25+
const state = billingUsageErrorState(error);
26+
const revalidator = useRevalidator();
27+
28+
return (
29+
<InfoPanel
30+
title={state.title}
31+
icon={ICONS[state.kind]}
32+
iconClassName={state.kind === "error" ? "text-error" : "text-text-dimmed"}
33+
panelClassName="max-w-md"
34+
accessory={
35+
retryable ? (
36+
<Button
37+
variant="tertiary/small"
38+
disabled={revalidator.state !== "idle"}
39+
onClick={() => revalidator.revalidate()}
40+
>
41+
Try again
42+
</Button>
43+
) : undefined
44+
}
45+
>
46+
{state.message}
47+
</InfoPanel>
48+
);
49+
}
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import type { BillingAddOns } from "@trigger.dev/billing";
2+
import { useEffect, useRef } from "react";
3+
import { MetricsLayout } from "~/components/layout/MetricsLayout";
4+
import { DateTime } from "~/components/primitives/DateTime";
5+
import { Paragraph } from "~/components/primitives/Paragraph";
6+
import { Select, SelectItem } from "~/components/primitives/Select";
7+
import { Spinner } from "~/components/primitives/Spinner";
8+
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
9+
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
10+
import { useSearchParams } from "~/hooks/useSearchParam";
11+
import type { BillingUsageResult } from "~/services/billingUsage.server";
12+
import {
13+
usageProjects,
14+
type MonthlyUsageView,
15+
type ProjectUsageView,
16+
type UsageDimension,
17+
} from "~/utils/billingUsageBreakdown";
18+
import {
19+
ALL_PROJECTS,
20+
browserGroupingsStore,
21+
parseUsagePageState,
22+
storedGroupingsToRestore,
23+
USAGE_PAGE_PARAMS,
24+
writeStoredGroupings,
25+
type GroupingsParam,
26+
type UsagePageTab,
27+
} from "~/utils/billingUsagePageState";
28+
import { AddOnsTab } from "./AddOnsTab";
29+
import { BillingUsageErrorPanel } from "./BillingUsageErrorPanel";
30+
import { MonthStepper } from "./MonthStepper";
31+
import { ProjectUsageTab } from "./ProjectUsageTab";
32+
import { UsageTab, type UsagePlanLimits } from "./UsageTab";
33+
import { useUsageFill } from "./useUsageFill";
34+
35+
const TABS: { value: UsagePageTab; label: string }[] = [
36+
{ value: "usage", label: "Usage" },
37+
{ value: "projects", label: "By project" },
38+
{ value: "addons", label: "Add-ons" },
39+
];
40+
41+
export function BillingUsageView({
42+
organizationSlug,
43+
months,
44+
month,
45+
addOns,
46+
limits,
47+
...initial
48+
}: {
49+
organizationSlug: string;
50+
months: string[];
51+
month: string;
52+
usage: BillingUsageResult<MonthlyUsageView>;
53+
projectUsage: BillingUsageResult<ProjectUsageView>;
54+
addOns: BillingUsageResult<BillingAddOns>;
55+
limits: UsagePlanLimits;
56+
}) {
57+
const { usage, projectUsage, pendingDays, isFilling } = useUsageFill(
58+
organizationSlug,
59+
month,
60+
initial
61+
);
62+
const location = useOptimisticLocation();
63+
const { replace } = useSearchParams();
64+
const projects = projectUsage.ok ? usageProjects(projectUsage.value) : [];
65+
const state = parseUsagePageState(
66+
new URLSearchParams(location.search),
67+
projects.map((project) => project.id)
68+
);
69+
70+
const hasRestored = useRef(false);
71+
useEffect(() => {
72+
if (hasRestored.current) return;
73+
hasRestored.current = true;
74+
75+
const restore = storedGroupingsToRestore(
76+
new URLSearchParams(location.search),
77+
browserGroupingsStore()
78+
);
79+
if (Object.keys(restore).length > 0) replace(restore);
80+
}, [location.search, replace]);
81+
82+
const changeGroupings = (param: GroupingsParam, groupings: UsageDimension[]) => {
83+
writeStoredGroupings(browserGroupingsStore(), param, groupings);
84+
replace({ [USAGE_PAGE_PARAMS[param]]: groupings.join(",") });
85+
};
86+
87+
const active = { usage, projects: projectUsage, addons: addOns }[state.tab];
88+
const fetchedAt = active.ok ? active.value.fetchedAt : undefined;
89+
90+
return (
91+
<MetricsLayout.Root>
92+
<MetricsLayout.Filters>
93+
<TabContainer>
94+
{TABS.map((tab) => (
95+
<TabButton
96+
key={tab.value}
97+
layoutId="billing-usage-tabs"
98+
isActive={state.tab === tab.value}
99+
onClick={() => replace({ [USAGE_PAGE_PARAMS.tab]: tab.value })}
100+
>
101+
{tab.label}
102+
</TabButton>
103+
))}
104+
</TabContainer>
105+
<div className="flex items-center gap-2">
106+
{state.tab === "projects" && projects.length > 0 && (
107+
<Select
108+
name="project"
109+
value={state.project}
110+
setValue={(value) => replace({ [USAGE_PAGE_PARAMS.project]: value })}
111+
items={[
112+
{ value: ALL_PROJECTS, label: "All projects" },
113+
...projects.map((project) => ({ value: project.id, label: project.name })),
114+
]}
115+
text={(value) =>
116+
projects.find((project) => project.id === value)?.name ?? "All projects"
117+
}
118+
dropdownIcon
119+
variant="tertiary/small"
120+
>
121+
{(items) =>
122+
items.map((item) => (
123+
<SelectItem key={item.value} value={item.value}>
124+
{item.label}
125+
</SelectItem>
126+
))
127+
}
128+
</Select>
129+
)}
130+
{pendingDays > 0 && state.tab !== "addons" && (
131+
<div className="flex items-center gap-1.5 whitespace-nowrap">
132+
{isFilling && <Spinner className="size-3.5" color="muted" />}
133+
<Paragraph variant="extra-small">
134+
{isFilling ? "Loading" : "Couldn't load"} {pendingDays} earlier{" "}
135+
{pendingDays === 1 ? "day" : "days"}
136+
</Paragraph>
137+
</div>
138+
)}
139+
{fetchedAt && (
140+
<Paragraph variant="extra-small" className="whitespace-nowrap">
141+
Updated <DateTime date={fetchedAt} />
142+
</Paragraph>
143+
)}
144+
<MonthStepper
145+
months={months}
146+
month={month}
147+
onChange={(value) => replace({ [USAGE_PAGE_PARAMS.month]: value })}
148+
/>
149+
</div>
150+
</MetricsLayout.Filters>
151+
{state.tab === "usage" ? (
152+
usage.ok ? (
153+
<UsageTab
154+
usage={usage.value}
155+
isFilling={isFilling}
156+
limits={limits}
157+
groupings={state.usageGroupings}
158+
onGroupingsChange={(groupings) => changeGroupings("usageGroupings", groupings)}
159+
/>
160+
) : (
161+
<ErrorGrid result={usage} />
162+
)
163+
) : state.tab === "addons" ? (
164+
addOns.ok ? (
165+
<AddOnsTab addOns={addOns.value} />
166+
) : (
167+
<ErrorGrid result={addOns} />
168+
)
169+
) : projectUsage.ok ? (
170+
<ProjectUsageTab
171+
usage={projectUsage.value}
172+
isFilling={isFilling}
173+
selectedProject={state.project}
174+
groupings={state.projectGroupings}
175+
metric={state.metric}
176+
timeFormat={state.timeFormat}
177+
onGroupingsChange={(groupings) => changeGroupings("projectGroupings", groupings)}
178+
onMetricChange={(metric) => replace({ [USAGE_PAGE_PARAMS.metric]: metric })}
179+
onTimeFormatChange={(timeFormat) =>
180+
replace({ [USAGE_PAGE_PARAMS.timeFormat]: timeFormat })
181+
}
182+
/>
183+
) : (
184+
<ErrorGrid result={projectUsage} />
185+
)}
186+
</MetricsLayout.Root>
187+
);
188+
}
189+
190+
function ErrorGrid({ result }: { result: Extract<BillingUsageResult<unknown>, { ok: false }> }) {
191+
return (
192+
<MetricsLayout.Grid columns={{ base: 1 }}>
193+
<BillingUsageErrorPanel error={result.error} retryable={result.retryable} />
194+
</MetricsLayout.Grid>
195+
);
196+
}

0 commit comments

Comments
 (0)