pr_01m47d15m3e54sn21z27rpy5n9/apps/web/app/routes/workspace/usage.tsx

311 lines12,314 bytesCodeBlame
1import { ArrowUpRight, CreditCard } from "lucide-react";
2import { Link, data } from "react-router";
3
4import { MICROS_PER_DOLLAR, type UsageSlice } from "@g1t/contracts";
5
6import type { Route } from "./+types/usage";
7import { usageTask } from "../../lib/billing";
8import { page } from "../../lib/meta";
9import { ButtonLink } from "../../components/ui";
10import { billing } from "../../lib/services.server";
11import { getViewer, roleIn, unwrap } from "../../lib/session.server";
12
13const PERIODS = {
14 month: "This month",
15 "7d": "Last 7 days",
16 "30d": "Last 30 days",
17 "90d": "Last 90 days",
18} as const;
19type Period = keyof typeof PERIODS;
20
21/** What each kind of agent work is called, and its colour. */
22const task = usageTask;
23
24function start(period: Period, now = new Date()): Date {
25 if (period === "month") return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
26 const days = Number(period.replace("d", ""));
27 const day = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
28 return new Date(day.getTime() - (days - 1) * 86_400_000);
29}
30
31export function meta({ params, ...args }: Route.MetaArgs) {
32 return page(args, { title: `Usage · ${params.owner} · g1t` });
33}
34
35export async function loader({ params, context, request }: Route.LoaderArgs) {
36 const viewer = getViewer(context);
37 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
38 const asked = new URL(request.url).searchParams.get("period");
39 const period: Period = asked && asked in PERIODS ? (asked as Period) : "month";
40 const since = start(period);
41 const [usage, account] = await Promise.all([
42 billing.usage(params.owner, viewer, since.toISOString()),
43 billing.account(params.owner, viewer),
44 ]);
45 return { period, since: since.toISOString(), usage: unwrap(usage), account: unwrap(account) };
46}
47
48function dollars(micros: number, digits = 2): string {
49 return `$${(micros / MICROS_PER_DOLLAR).toFixed(digits)}`;
50}
51
52function Stat({ label, value, note }: { label: string; value: string; note?: string }) {
53 return (
54 <div className="rounded-2xl bg-surface p-5 ring-1 ring-line">
55 <p className="text-sm text-muted">{label}</p>
56 <p className="mt-2 text-3xl font-semibold tracking-tight tabular-nums">{value}</p>
57 {note && <p className="mt-1 text-xs text-faint">{note}</p>}
58 </div>
59 );
60}
61
62/** Spend per day, stacked by kind of work. */
63function Chart({ since, slices }: { since: string; slices: UsageSlice[] }) {
64 const first = new Date(since);
65 const today = new Date();
66 const days: string[] = [];
67 for (let day = first; day <= today; day = new Date(day.getTime() + 86_400_000)) {
68 days.push(day.toISOString().slice(0, 10));
69 }
70 const byDay = new Map<string, { task: string; micros: number }[]>();
71 for (const slice of slices) {
72 const [day, kind] = slice.key.split("/");
73 byDay.set(day!, [...(byDay.get(day!) ?? []), { task: kind ?? "other", micros: slice.micros }]);
74 }
75 const totals = days.map((day) => (byDay.get(day) ?? []).reduce((sum, part) => sum + part.micros, 0));
76 const max = Math.max(...totals, 1);
77 const height = 160;
78 const width = 100 / days.length;
79 return (
80 <div>
81 <div className="relative" style={{ height }}>
82 {/* Gridlines at a quarter, a half and three quarters of the highest day. */}
83 {[0.25, 0.5, 0.75, 1].map((at) => (
84 <div key={at} className="absolute inset-x-0 border-t border-line/60" style={{ bottom: `${at * 100}%` }}>
85 <span className="absolute -top-2.5 right-0 bg-surface pl-1 font-mono text-[0.625rem] text-faint">
86 {dollars(max * at)}
87 </span>
88 </div>
89 ))}
90 <div className="absolute inset-0 flex items-end gap-px pr-12">
91 {days.map((day, index) => {
92 const parts = byDay.get(day) ?? [];
93 return (
94 <div
95 key={day}
96 className="group relative flex h-full flex-col justify-end"
97 style={{ width: `${width}%` }}
98 title={`${day}: ${dollars(totals[index]!)}`}
99 >
100 {parts.map((part) => (
101 <div
102 key={part.task}
103 className="w-full first:rounded-t-sm"
104 style={{ height: `${(part.micros / max) * 100}%`, background: task(part.task).color }}
105 />
106 ))}
107 <div className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-2 hidden -translate-x-1/2 rounded-md bg-raised px-2 py-1 text-xs whitespace-nowrap ring-1 ring-line-strong group-hover:block">
108 <span className="text-muted">{day}</span> {dollars(totals[index]!)}
109 </div>
110 </div>
111 );
112 })}
113 </div>
114 </div>
115 <div className="mt-2 flex justify-between pr-12 font-mono text-[0.625rem] text-faint">
116 <span>{days[0]}</span>
117 <span>{days[days.length - 1]}</span>
118 </div>
119 </div>
120 );
121}
122
123function Breakdown({
124 title,
125 slices,
126 total,
127 label,
128 link,
129 color,
130}: {
131 title: string;
132 slices: UsageSlice[];
133 total: number;
134 label?: (key: string) => string;
135 link?: (key: string) => string | null;
136 color?: (key: string) => string;
137}) {
138 return (
139 <section className="rounded-2xl bg-surface p-5 ring-1 ring-line">
140 <h3 className="text-sm font-medium">{title}</h3>
141 {slices.length === 0 ? (
142 <p className="mt-4 text-sm text-faint">Nothing in this period.</p>
143 ) : (
144 <ul className="mt-4 space-y-3">
145 {slices.map((slice) => {
146 const to = link?.(slice.key);
147 const name = label ? label(slice.key) : slice.key;
148 return (
149 <li key={slice.key}>
150 <div className="flex items-baseline justify-between gap-3 text-sm">
151 <span className="min-w-0 truncate">
152 {to ? (
153 <Link to={to} className="hover:underline">
154 {name}
155 </Link>
156 ) : (
157 name
158 )}
159 </span>
160 <span className="shrink-0 font-mono text-xs tabular-nums">
161 {dollars(slice.micros)} <span className="text-faint">· {slice.runs} {slice.runs === 1 ? "run" : "runs"}</span>
162 </span>
163 </div>
164 <div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-raised">
165 <div
166 className="h-full rounded-full"
167 style={{
168 width: `${total ? (slice.micros / total) * 100 : 0}%`,
169 background: color?.(slice.key) ?? "var(--color-merged)",
170 }}
171 />
172 </div>
173 </li>
174 );
175 })}
176 </ul>
177 )}
178 </section>
179 );
180}
181
182export default function UsagePage({ loaderData, params }: Route.ComponentProps) {
183 const { period, since, usage, account } = loaderData;
184 const base = `/${params.owner}`;
185 const days = Math.max(1, Math.ceil((Date.now() - new Date(since).getTime()) / 86_400_000));
186 // While g1t is free nothing is charged, so usage is measured at cost
187 // and credit is not drawn down.
188 const total = usage.free ? usage.usedMicros : usage.spentMicros;
189 const perDay = usage.free ? 0 : total / days;
190 const runway = perDay > 0 ? Math.floor(account.balanceMicros / perDay) : null;
191 return (
192 <div className="space-y-8">
193 <div className="flex flex-wrap items-end justify-end gap-4">
194 <nav className="flex rounded-lg bg-surface p-1 ring-1 ring-line">
195 {(Object.keys(PERIODS) as Period[]).map((key) => (
196 <Link
197 key={key}
198 to={`?period=${key}`}
199 preventScrollReset
200 className={`rounded-md px-3 py-1.5 text-sm transition-colors ${
201 key === period ? "bg-raised text-fg" : "text-muted hover:text-fg"
202 }`}
203 >
204 {PERIODS[key]}
205 </Link>
206 ))}
207 </nav>
208 </div>
209
210 <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
211 {usage.free ? (
212 <Stat
213 label="Used"
214 value={dollars(total)}
215 note="At cost. Nothing is charged while g1t is being built out."
216 />
217 ) : (
218 <Stat
219 label="Spent"
220 value={dollars(usage.spentMicros)}
221 note={
222 usage.providerMicros > 0
223 ? `Plus about ${dollars(usage.providerMicros)} billed by your own model provider`
224 : `${dollars(usage.costMicros)} of it the model provider's`
225 }
226 />
227 )}
228 <Stat label="Agent runs" value={String(usage.runs)} note={PERIODS[period]} />
229 <Stat
230 label="Average run"
231 value={usage.runs ? dollars(total / usage.runs, 3) : "—"}
232 note="Making a change, reviewing, revising…"
233 />
234 <div className="rounded-2xl bg-surface p-5 ring-1 ring-line">
235 <p className="flex items-center justify-between text-sm text-muted">
236 Credit left
237 {!usage.free && (
238 <Link to={`${base}/-/billing`} className="text-xs text-accent hover:underline">
239 Add credit
240 </Link>
241 )}
242 </p>
243 <p className={`mt-2 text-3xl font-semibold tracking-tight tabular-nums ${account.balanceMicros <= 0 ? "text-warn" : ""}`}>
244 {dollars(account.balanceMicros)}
245 </p>
246 <p className="mt-1 text-xs text-faint">
247 {usage.free
248 ? "Not drawn down while g1t is free."
249 : runway == null
250 ? "Nothing spent in this period."
251 : `About ${runway} ${runway === 1 ? "day" : "days"} at this rate.`}
252 </p>
253 </div>
254 </div>
255
256 <section className="rounded-2xl bg-surface p-5 ring-1 ring-line">
257 <div className="flex flex-wrap items-baseline justify-between gap-3">
258 <h3 className="text-sm font-medium">{usage.free ? "Usage per day" : "Spend per day"}</h3>
259 <ul className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted">
260 {usage.byTask.map((slice) => (
261 <li key={slice.key} className="flex items-center gap-1.5">
262 <span className="size-2 rounded-sm" style={{ background: task(slice.key).color }} />
263 {task(slice.key).label}
264 </li>
265 ))}
266 </ul>
267 </div>
268 <div className="mt-6">
269 <Chart since={since} slices={usage.byDay} />
270 </div>
271 </section>
272
273 <div className="grid gap-4 lg:grid-cols-2">
274 <Breakdown
275 title="By kind of work"
276 slices={usage.byTask}
277 total={total}
278 label={(key) => task(key).label}
279 color={(key) => task(key).color}
280 />
281 <Breakdown title="By repository" slices={usage.byRepo} total={total} link={(key) => `/${key}`} />
282 <Breakdown
283 title="Pull requests that cost most"
284 slices={usage.byPull}
285 total={usage.byPull[0]?.micros ?? 0}
286 // Planning runs belong to a repository, not a pull request.
287 label={(key) => (key.endsWith("#0") ? `${key.slice(0, -2)} · planning` : key)}
288 link={(key) => {
289 const [repo, number] = key.split("#");
290 if (!repo) return null;
291 return number && number !== "0" ? `/${repo}/pull/${number}` : `/${repo}/plans`;
292 }}
293 />
294 <Breakdown title="By model" slices={usage.byModel} total={total} color={() => "var(--color-info)"} />
295 </div>
296
297 <div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl bg-surface px-5 py-4 text-sm ring-1 ring-line">
298 <span className="flex items-center gap-2 text-muted">
299 <CreditCard size={15} />
300 {usage.addedMicros > 0
301 ? `${dollars(usage.addedMicros)} of credit added in this period.`
302 : "Every charge and payment is on the workspace's statement."}
303 </span>
304 <ButtonLink to={`${base}/-/billing`} variant="quiet">
305 Statement and credit
306 <ArrowUpRight size={14} />
307 </ButtonLink>
308 </div>
309 </div>
310 );
311}