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

331 lines13,736 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request1import { 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";
Fixes from the page sweep7import { foldTasks, planStatus, usageTask } from "../../lib/billing";
Social cards for every page: og.g1t.sh8import { page } from "../../lib/meta";
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request9import { 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. */
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look22const task = usageTask;
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request23
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
Social cards for every page: og.g1t.sh31export function meta({ params, ...args }: Route.MetaArgs) {
32 return page(args, { title: `Usage · ${params.owner} · g1t` });
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request33}
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);
Fixes from the page sweep41 const [usage, account, features, entitlements] = await Promise.all([
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request42 billing.usage(params.owner, viewer, since.toISOString()),
43 billing.account(params.owner, viewer),
Fixes from the page sweep44 billing.features(params.owner, viewer).catch(() => null),
45 billing.entitlements(params.owner).catch(() => null),
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request46 ]);
Fixes from the page sweep47 const plan = features?.ok ? (features.value.find((state) => state.plan.feature === "plan") ?? null) : null;
48 // A comped workspace is charged nothing, so it has no credit to run down.
49 const comped = planStatus(plan, entitlements).kind === "comped";
50 return { period, since: since.toISOString(), usage: unwrap(usage), account: unwrap(account), comped };
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request51}
52
53function dollars(micros: number, digits = 2): string {
54 return `$${(micros / MICROS_PER_DOLLAR).toFixed(digits)}`;
55}
56
57function Stat({ label, value, note }: { label: string; value: string; note?: string }) {
58 return (
59 <div className="rounded-2xl bg-surface p-5 ring-1 ring-line">
60 <p className="text-sm text-muted">{label}</p>
61 <p className="mt-2 text-3xl font-semibold tracking-tight tabular-nums">{value}</p>
62 {note && <p className="mt-1 text-xs text-faint">{note}</p>}
63 </div>
64 );
65}
66
67/** Spend per day, stacked by kind of work. */
68function Chart({ since, slices }: { since: string; slices: UsageSlice[] }) {
69 const first = new Date(since);
70 const today = new Date();
71 const days: string[] = [];
72 for (let day = first; day <= today; day = new Date(day.getTime() + 86_400_000)) {
73 days.push(day.toISOString().slice(0, 10));
74 }
75 const byDay = new Map<string, { task: string; micros: number }[]>();
76 for (const slice of slices) {
77 const [day, kind] = slice.key.split("/");
78 byDay.set(day!, [...(byDay.get(day!) ?? []), { task: kind ?? "other", micros: slice.micros }]);
79 }
80 const totals = days.map((day) => (byDay.get(day) ?? []).reduce((sum, part) => sum + part.micros, 0));
81 const max = Math.max(...totals, 1);
82 const height = 160;
83 const width = 100 / days.length;
84 return (
85 <div>
86 <div className="relative" style={{ height }}>
87 {/* Gridlines at a quarter, a half and three quarters of the highest day. */}
88 {[0.25, 0.5, 0.75, 1].map((at) => (
89 <div key={at} className="absolute inset-x-0 border-t border-line/60" style={{ bottom: `${at * 100}%` }}>
90 <span className="absolute -top-2.5 right-0 bg-surface pl-1 font-mono text-[0.625rem] text-faint">
91 {dollars(max * at)}
92 </span>
93 </div>
94 ))}
95 <div className="absolute inset-0 flex items-end gap-px pr-12">
96 {days.map((day, index) => {
97 const parts = byDay.get(day) ?? [];
98 return (
99 <div
100 key={day}
101 className="group relative flex h-full flex-col justify-end"
102 style={{ width: `${width}%` }}
103 title={`${day}: ${dollars(totals[index]!)}`}
104 >
105 {parts.map((part) => (
106 <div
107 key={part.task}
108 className="w-full first:rounded-t-sm"
109 style={{ height: `${(part.micros / max) * 100}%`, background: task(part.task).color }}
110 />
111 ))}
112 <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">
113 <span className="text-muted">{day}</span> {dollars(totals[index]!)}
114 </div>
115 </div>
116 );
117 })}
118 </div>
119 </div>
120 <div className="mt-2 flex justify-between pr-12 font-mono text-[0.625rem] text-faint">
121 <span>{days[0]}</span>
122 <span>{days[days.length - 1]}</span>
123 </div>
124 </div>
125 );
126}
127
128function Breakdown({
129 title,
130 slices,
131 total,
132 label,
133 link,
134 color,
135}: {
136 title: string;
137 slices: UsageSlice[];
138 total: number;
139 label?: (key: string) => string;
140 link?: (key: string) => string | null;
141 color?: (key: string) => string;
142}) {
143 return (
144 <section className="rounded-2xl bg-surface p-5 ring-1 ring-line">
145 <h3 className="text-sm font-medium">{title}</h3>
146 {slices.length === 0 ? (
147 <p className="mt-4 text-sm text-faint">Nothing in this period.</p>
148 ) : (
149 <ul className="mt-4 space-y-3">
150 {slices.map((slice) => {
151 const to = link?.(slice.key);
152 const name = label ? label(slice.key) : slice.key;
153 return (
154 <li key={slice.key}>
155 <div className="flex items-baseline justify-between gap-3 text-sm">
156 <span className="min-w-0 truncate">
157 {to ? (
158 <Link to={to} className="hover:underline">
159 {name}
160 </Link>
161 ) : (
162 name
163 )}
164 </span>
165 <span className="shrink-0 font-mono text-xs tabular-nums">
166 {dollars(slice.micros)} <span className="text-faint">· {slice.runs} {slice.runs === 1 ? "run" : "runs"}</span>
167 </span>
168 </div>
169 <div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-raised">
170 <div
171 className="h-full rounded-full"
172 style={{
173 width: `${total ? (slice.micros / total) * 100 : 0}%`,
174 background: color?.(slice.key) ?? "var(--color-merged)",
175 }}
176 />
177 </div>
178 </li>
179 );
180 })}
181 </ul>
182 )}
183 </section>
184 );
185}
186
187export default function UsagePage({ loaderData, params }: Route.ComponentProps) {
Fixes from the page sweep188 const { period, since, usage, account, comped } = loaderData;
Usage lists Other once: kinds of work without words of their own are added into it189 const byTask = foldTasks(usage.byTask);
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request190 const base = `/${params.owner}`;
191 const days = Math.max(1, Math.ceil((Date.now() - new Date(since).getTime()) / 86_400_000));
Usage while free is shown at cost; agents get rustfmt and clippy192 // While g1t is free nothing is charged, so usage is measured at cost
193 // and credit is not drawn down.
A comped workspace's Usage shows what its runs used at cost, not $0.00 spent and a $0.000 average194 // Free or comped, nothing is charged: what the runs used, at cost, is
195 // what there is to show.
196 const atCost = usage.free || comped;
197 const total = atCost ? usage.usedMicros : usage.spentMicros;
198 const perDay = atCost ? 0 : total / days;
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request199 const runway = perDay > 0 ? Math.floor(account.balanceMicros / perDay) : null;
200 return (
201 <div className="space-y-8">
A catalogue of model providers, and settings that feel like settings202 <div className="flex flex-wrap items-end justify-end gap-4">
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request203 <nav className="flex rounded-lg bg-surface p-1 ring-1 ring-line">
204 {(Object.keys(PERIODS) as Period[]).map((key) => (
205 <Link
206 key={key}
207 to={`?period=${key}`}
208 preventScrollReset
209 className={`rounded-md px-3 py-1.5 text-sm transition-colors ${
210 key === period ? "bg-raised text-fg" : "text-muted hover:text-fg"
211 }`}
212 >
213 {PERIODS[key]}
214 </Link>
215 ))}
216 </nav>
217 </div>
218
219 <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
A comped workspace's Usage shows what its runs used at cost, not $0.00 spent and a $0.000 average220 {atCost ? (
Usage while free is shown at cost; agents get rustfmt and clippy221 <Stat
222 label="Used"
223 value={dollars(total)}
A comped workspace's Usage shows what its runs used at cost, not $0.00 spent and a $0.000 average224 note={comped ? "At cost. The workspace is comped: nothing is charged." : "At cost. Nothing is charged while g1t is being built out."}
Usage while free is shown at cost; agents get rustfmt and clippy225 />
226 ) : (
227 <Stat
228 label="Spent"
229 value={dollars(usage.spentMicros)}
230 note={
Billing's usage total is labeled at price, and Usage says what part of it was paid for231 (usage.coveredMicros ?? 0) > 0
232 ? `Of ${dollars(usage.spentMicros + (usage.coveredMicros ?? 0))} at price: ${dollars(usage.coveredMicros ?? 0)} of it was paid by the included usage, the trial or a pool`
233 : usage.providerMicros > 0
234 ? `Plus about ${dollars(usage.providerMicros)} billed by your own model provider`
235 : `${dollars(usage.costMicros)} of it the model provider's`
Usage while free is shown at cost; agents get rustfmt and clippy236 }
237 />
238 )}
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request239 <Stat label="Agent runs" value={String(usage.runs)} note={PERIODS[period]} />
240 <Stat
241 label="Average run"
Usage while free is shown at cost; agents get rustfmt and clippy242 value={usage.runs ? dollars(total / usage.runs, 3) : "—"}
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request243 note="Making a change, reviewing, revising…"
244 />
Fixes from the page sweep245 {comped ? (
246 // Nothing is charged to a comped workspace: no credit to run down.
247 <div className="rounded-2xl bg-surface p-5 ring-1 ring-line">
248 <p className="text-sm text-muted">Credit</p>
249 <p className="mt-2 text-3xl font-semibold tracking-tight text-accent">Comped</p>
250 <p className="mt-1 text-xs text-faint">Recorded at what it costs; nothing is charged to this workspace.</p>
251 </div>
252 ) : (
253 <div className="rounded-2xl bg-surface p-5 ring-1 ring-line">
254 <p className="flex items-center justify-between text-sm text-muted">
255 Credit left
256 {!usage.free && (
257 <Link to={`${base}/-/billing`} className="text-xs text-accent hover:underline">
258 Add credit
259 </Link>
260 )}
261 </p>
262 <p className={`mt-2 text-3xl font-semibold tracking-tight tabular-nums ${account.balanceMicros <= 0 ? "text-warn" : ""}`}>
263 {dollars(account.balanceMicros)}
264 </p>
265 <p className="mt-1 text-xs text-faint">
266 {usage.free
267 ? "Not drawn down while g1t is free."
268 : runway == null
269 ? "Nothing spent in this period."
270 : `About ${runway} ${runway === 1 ? "day" : "days"} at this rate.`}
271 </p>
272 </div>
273 )}
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request274 </div>
275
276 <section className="rounded-2xl bg-surface p-5 ring-1 ring-line">
277 <div className="flex flex-wrap items-baseline justify-between gap-3">
Usage per day, not spend, while nothing is charged278 <h3 className="text-sm font-medium">{atCost ? "Usage per day" : "Spend per day"}</h3>
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request279 <ul className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted">
Usage lists Other once: kinds of work without words of their own are added into it280 {byTask.map((slice) => (
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request281 <li key={slice.key} className="flex items-center gap-1.5">
282 <span className="size-2 rounded-sm" style={{ background: task(slice.key).color }} />
283 {task(slice.key).label}
284 </li>
285 ))}
286 </ul>
287 </div>
288 <div className="mt-6">
289 <Chart since={since} slices={usage.byDay} />
290 </div>
291 </section>
292
293 <div className="grid gap-4 lg:grid-cols-2">
294 <Breakdown
295 title="By kind of work"
Usage lists Other once: kinds of work without words of their own are added into it296 slices={byTask}
Usage while free is shown at cost; agents get rustfmt and clippy297 total={total}
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request298 label={(key) => task(key).label}
299 color={(key) => task(key).color}
300 />
Usage while free is shown at cost; agents get rustfmt and clippy301 <Breakdown title="By repository" slices={usage.byRepo} total={total} link={(key) => `/${key}`} />
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request302 <Breakdown
303 title="Pull requests that cost most"
304 slices={usage.byPull}
305 total={usage.byPull[0]?.micros ?? 0}
306 // Planning runs belong to a repository, not a pull request.
307 label={(key) => (key.endsWith("#0") ? `${key.slice(0, -2)} · planning` : key)}
308 link={(key) => {
309 const [repo, number] = key.split("#");
310 if (!repo) return null;
311 return number && number !== "0" ? `/${repo}/pull/${number}` : `/${repo}/plans`;
312 }}
313 />
Usage while free is shown at cost; agents get rustfmt and clippy314 <Breakdown title="By model" slices={usage.byModel} total={total} color={() => "var(--color-info)"} />
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request315 </div>
316
317 <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">
318 <span className="flex items-center gap-2 text-muted">
319 <CreditCard size={15} />
320 {usage.addedMicros > 0
321 ? `${dollars(usage.addedMicros)} of credit added in this period.`
322 : "Every charge and payment is on the workspace's statement."}
323 </span>
324 <ButtonLink to={`${base}/-/billing`} variant="quiet">
325 Statement and credit
326 <ArrowUpRight size={14} />
327 </ButtonLink>
328 </div>
329 </div>
330 );
331}