flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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

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