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

312 lines12,355 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 { foldTasks, 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 byTask = foldTasks(usage.byTask);
185 const base = `/${params.owner}`;
186 const days = Math.max(1, Math.ceil((Date.now() - new Date(since).getTime()) / 86_400_000));
187 // While g1t is free nothing is charged, so usage is measured at cost
188 // and credit is not drawn down.
189 const total = usage.free ? usage.usedMicros : usage.spentMicros;
190 const perDay = usage.free ? 0 : total / days;
191 const runway = perDay > 0 ? Math.floor(account.balanceMicros / perDay) : null;
192 return (
193 <div className="space-y-8">
194 <div className="flex flex-wrap items-end justify-end gap-4">
195 <nav className="flex rounded-lg bg-surface p-1 ring-1 ring-line">
196 {(Object.keys(PERIODS) as Period[]).map((key) => (
197 <Link
198 key={key}
199 to={`?period=${key}`}
200 preventScrollReset
201 className={`rounded-md px-3 py-1.5 text-sm transition-colors ${
202 key === period ? "bg-raised text-fg" : "text-muted hover:text-fg"
203 }`}
204 >
205 {PERIODS[key]}
206 </Link>
207 ))}
208 </nav>
209 </div>
210
211 <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
212 {usage.free ? (
213 <Stat
214 label="Used"
215 value={dollars(total)}
216 note="At cost. Nothing is charged while g1t is being built out."
217 />
218 ) : (
219 <Stat
220 label="Spent"
221 value={dollars(usage.spentMicros)}
222 note={
223 usage.providerMicros > 0
224 ? `Plus about ${dollars(usage.providerMicros)} billed by your own model provider`
225 : `${dollars(usage.costMicros)} of it the model provider's`
226 }
227 />
228 )}
229 <Stat label="Agent runs" value={String(usage.runs)} note={PERIODS[period]} />
230 <Stat
231 label="Average run"
232 value={usage.runs ? dollars(total / usage.runs, 3) : "—"}
233 note="Making a change, reviewing, revising…"
234 />
235 <div className="rounded-2xl bg-surface p-5 ring-1 ring-line">
236 <p className="flex items-center justify-between text-sm text-muted">
237 Credit left
238 {!usage.free && (
239 <Link to={`${base}/-/billing`} className="text-xs text-accent hover:underline">
240 Add credit
241 </Link>
242 )}
243 </p>
244 <p className={`mt-2 text-3xl font-semibold tracking-tight tabular-nums ${account.balanceMicros <= 0 ? "text-warn" : ""}`}>
245 {dollars(account.balanceMicros)}
246 </p>
247 <p className="mt-1 text-xs text-faint">
248 {usage.free
249 ? "Not drawn down while g1t is free."
250 : runway == null
251 ? "Nothing spent in this period."
252 : `About ${runway} ${runway === 1 ? "day" : "days"} at this rate.`}
253 </p>
254 </div>
255 </div>
256
257 <section className="rounded-2xl bg-surface p-5 ring-1 ring-line">
258 <div className="flex flex-wrap items-baseline justify-between gap-3">
259 <h3 className="text-sm font-medium">{usage.free ? "Usage per day" : "Spend per day"}</h3>
260 <ul className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted">
261 {byTask.map((slice) => (
262 <li key={slice.key} className="flex items-center gap-1.5">
263 <span className="size-2 rounded-sm" style={{ background: task(slice.key).color }} />
264 {task(slice.key).label}
265 </li>
266 ))}
267 </ul>
268 </div>
269 <div className="mt-6">
270 <Chart since={since} slices={usage.byDay} />
271 </div>
272 </section>
273
274 <div className="grid gap-4 lg:grid-cols-2">
275 <Breakdown
276 title="By kind of work"
277 slices={byTask}
278 total={total}
279 label={(key) => task(key).label}
280 color={(key) => task(key).color}
281 />
282 <Breakdown title="By repository" slices={usage.byRepo} total={total} link={(key) => `/${key}`} />
283 <Breakdown
284 title="Pull requests that cost most"
285 slices={usage.byPull}
286 total={usage.byPull[0]?.micros ?? 0}
287 // Planning runs belong to a repository, not a pull request.
288 label={(key) => (key.endsWith("#0") ? `${key.slice(0, -2)} · planning` : key)}
289 link={(key) => {
290 const [repo, number] = key.split("#");
291 if (!repo) return null;
292 return number && number !== "0" ? `/${repo}/pull/${number}` : `/${repo}/plans`;
293 }}
294 />
295 <Breakdown title="By model" slices={usage.byModel} total={total} color={() => "var(--color-info)"} />
296 </div>
297
298 <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">
299 <span className="flex items-center gap-2 text-muted">
300 <CreditCard size={15} />
301 {usage.addedMicros > 0
302 ? `${dollars(usage.addedMicros)} of credit added in this period.`
303 : "Every charge and payment is on the workspace's statement."}
304 </span>
305 <ButtonLink to={`${base}/-/billing`} variant="quiet">
306 Statement and credit
307 <ArrowUpRight size={14} />
308 </ButtonLink>
309 </div>
310 </div>
311 );
312}